From d93c3b37194a9d7b7da7db534ed0f7f7f457ae5d Mon Sep 17 00:00:00 2001 From: EYHN Date: Wed, 11 Sep 2024 07:55:37 +0000 Subject: [PATCH] feat(core): user data db (#7930) --- packages/common/infra/src/index.ts | 1 + .../infra/src/modules/db/entities/db.ts | 13 +- .../infra/src/modules/db/entities/table.ts | 6 +- packages/common/infra/src/modules/db/index.ts | 8 +- .../infra/src/modules/db/services/db.ts | 57 ++-- packages/common/infra/src/orm/index.ts | 9 +- .../modules/editor-settting/impls/user-db.ts | 84 ++++++ .../core/src/modules/editor-settting/index.ts | 6 +- packages/frontend/core/src/modules/index.ts | 2 + .../userspace/entities/current-user-db.ts | 34 +++ .../userspace/entities/user-db-engine.ts | 34 +++ .../userspace/entities/user-db-table.ts | 35 +++ .../src/modules/userspace/entities/user-db.ts | 46 +++ .../userspace/impls/indexeddb-storage.ts | 266 ++++++++++++++++++ .../modules/userspace/impls/sqlite-storage.ts | 185 ++++++++++++ .../userspace/impls/user-db-doc-server.ts | 195 +++++++++++++ .../core/src/modules/userspace/index.ts | 40 +++ .../src/modules/userspace/provider/storage.ts | 8 + .../src/modules/userspace/schema/index.ts | 9 + .../modules/userspace/services/userspace.ts | 35 +++ .../impls/engine/blob-sqlite.ts | 7 +- .../impls/engine/doc-sqlite.ts | 30 +- packages/frontend/electron/renderer/app.tsx | 2 + .../electron/src/helper/db/ensure-db.ts | 23 +- .../frontend/electron/src/helper/db/index.ts | 133 +++++---- .../frontend/electron/src/helper/db/types.ts | 1 + .../src/helper/db/workspace-db-adapter.ts | 12 +- .../electron/src/helper/dialog/dialog.ts | 21 +- .../electron/src/helper/dialog/index.ts | 4 - .../electron/src/helper/workspace/handlers.ts | 70 +---- .../electron/src/helper/workspace/index.ts | 22 +- .../electron/src/helper/workspace/meta.ts | 40 ++- .../electron/test/db/ensure-db.spec.ts | 14 +- .../test/db/workspace-db-adapter.spec.ts | 4 +- .../electron/test/workspace/handlers.spec.ts | 43 +-- packages/frontend/web/src/app.tsx | 2 + 36 files changed, 1220 insertions(+), 281 deletions(-) create mode 100644 packages/frontend/core/src/modules/editor-settting/impls/user-db.ts create mode 100644 packages/frontend/core/src/modules/userspace/entities/current-user-db.ts create mode 100644 packages/frontend/core/src/modules/userspace/entities/user-db-engine.ts create mode 100644 packages/frontend/core/src/modules/userspace/entities/user-db-table.ts create mode 100644 packages/frontend/core/src/modules/userspace/entities/user-db.ts create mode 100644 packages/frontend/core/src/modules/userspace/impls/indexeddb-storage.ts create mode 100644 packages/frontend/core/src/modules/userspace/impls/sqlite-storage.ts create mode 100644 packages/frontend/core/src/modules/userspace/impls/user-db-doc-server.ts create mode 100644 packages/frontend/core/src/modules/userspace/index.ts create mode 100644 packages/frontend/core/src/modules/userspace/provider/storage.ts create mode 100644 packages/frontend/core/src/modules/userspace/schema/index.ts create mode 100644 packages/frontend/core/src/modules/userspace/services/userspace.ts create mode 100644 packages/frontend/electron/src/helper/db/types.ts diff --git a/packages/common/infra/src/index.ts b/packages/common/infra/src/index.ts index 67e67b3a91..07b6c95cb7 100644 --- a/packages/common/infra/src/index.ts +++ b/packages/common/infra/src/index.ts @@ -11,6 +11,7 @@ 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'; diff --git a/packages/common/infra/src/modules/db/entities/db.ts b/packages/common/infra/src/modules/db/entities/db.ts index 0ea850b220..feb2d33465 100644 --- a/packages/common/infra/src/modules/db/entities/db.ts +++ b/packages/common/infra/src/modules/db/entities/db.ts @@ -1,8 +1,8 @@ import { Entity } from '../../../framework'; import type { DBSchemaBuilder, TableMap } from '../../../orm'; -import { Table } from './table'; +import { WorkspaceDBTable } from './table'; -export class DB extends Entity<{ +export class WorkspaceDB extends Entity<{ db: TableMap; schema: Schema; storageDocId: (tableName: string) => string; @@ -12,7 +12,7 @@ export class DB extends Entity<{ constructor() { super(); Object.entries(this.props.schema).forEach(([tableName]) => { - const table = this.framework.createEntity(Table, { + const table = this.framework.createEntity(WorkspaceDBTable, { table: this.db[tableName], storageDocId: this.props.storageDocId(tableName), }); @@ -23,6 +23,7 @@ export class DB extends Entity<{ } } -export type DBWithTables = DB & { - [K in keyof Schema]: Table; -}; +export type WorkspaceDBWithTables = + WorkspaceDB & { + [K in keyof Schema]: WorkspaceDBTable; + }; diff --git a/packages/common/infra/src/modules/db/entities/table.ts b/packages/common/infra/src/modules/db/entities/table.ts index c300ce9968..348493d99c 100644 --- a/packages/common/infra/src/modules/db/entities/table.ts +++ b/packages/common/infra/src/modules/db/entities/table.ts @@ -1,8 +1,10 @@ import { Entity } from '../../../framework'; -import type { Table as OrmTable, TableSchemaBuilder } from '../../../orm/core'; +import type { Table as OrmTable, TableSchemaBuilder } from '../../../orm'; import type { WorkspaceService } from '../../workspace'; -export class Table extends Entity<{ +export class WorkspaceDBTable< + Schema extends TableSchemaBuilder, +> extends Entity<{ table: OrmTable; storageDocId: string; }> { diff --git a/packages/common/infra/src/modules/db/index.ts b/packages/common/infra/src/modules/db/index.ts index a122d37931..ffc62aa93d 100644 --- a/packages/common/infra/src/modules/db/index.ts +++ b/packages/common/infra/src/modules/db/index.ts @@ -1,7 +1,7 @@ import type { Framework } from '../../framework'; import { WorkspaceScope, WorkspaceService } from '../workspace'; -import { DB } from './entities/db'; -import { Table } from './entities/table'; +import { WorkspaceDB } from './entities/db'; +import { WorkspaceDBTable } from './entities/table'; import { WorkspaceDBService } from './services/db'; export { AFFiNE_WORKSPACE_DB_SCHEMA } from './schema'; @@ -12,6 +12,6 @@ export function configureWorkspaceDBModule(framework: Framework) { framework .scope(WorkspaceScope) .service(WorkspaceDBService, [WorkspaceService]) - .entity(DB) - .entity(Table, [WorkspaceService]); + .entity(WorkspaceDB) + .entity(WorkspaceDBTable, [WorkspaceService]); } diff --git a/packages/common/infra/src/modules/db/services/db.ts b/packages/common/infra/src/modules/db/services/db.ts index fafbc87ec0..8d41bc998d 100644 --- a/packages/common/infra/src/modules/db/services/db.ts +++ b/packages/common/infra/src/modules/db/services/db.ts @@ -5,7 +5,7 @@ import { createORMClient, YjsDBAdapter } from '../../../orm'; import type { DocStorage } from '../../../sync'; import { ObjectPool } from '../../../utils'; import type { WorkspaceService } from '../../workspace'; -import { DB, type DBWithTables } from '../entities/db'; +import { WorkspaceDB, type WorkspaceDBWithTables } from '../entities/db'; import { AFFiNE_WORKSPACE_DB_SCHEMA } from '../schema'; import { AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA } from '../schema/schema'; @@ -15,10 +15,10 @@ const WorkspaceUserdataDBClient = createORMClient( ); export class WorkspaceDBService extends Service { - db: DBWithTables; + db: WorkspaceDBWithTables; userdataDBPool = new ObjectPool< string, - DB + WorkspaceDB >({ onDangling() { return false; // never release @@ -27,27 +27,30 @@ export class WorkspaceDBService extends Service { constructor(private readonly workspaceService: WorkspaceService) { super(); - this.db = this.framework.createEntity(DB, { - 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 DBWithTables; + this.db = this.framework.createEntity( + WorkspaceDB, + { + 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; } // eslint-disable-next-line @typescript-eslint/ban-types @@ -55,11 +58,11 @@ export class WorkspaceDBService extends Service { // __local__ for local workspace const userdataDb = this.userdataDBPool.get(userId); if (userdataDb) { - return userdataDb.obj as DBWithTables; + return userdataDb.obj as WorkspaceDBWithTables; } const newDB = this.framework.createEntity( - DB, + WorkspaceDB, { db: new WorkspaceUserdataDBClient( new YjsDBAdapter(AFFiNE_WORKSPACE_USERDATA_DB_SCHEMA, { @@ -84,7 +87,7 @@ export class WorkspaceDBService extends Service { ); this.userdataDBPool.put(userId, newDB); - return newDB as DBWithTables; + return newDB as WorkspaceDBWithTables; } static isDBDocId(docId: string) { diff --git a/packages/common/infra/src/orm/index.ts b/packages/common/infra/src/orm/index.ts index 01143d4466..04e5e62a2d 100644 --- a/packages/common/infra/src/orm/index.ts +++ b/packages/common/infra/src/orm/index.ts @@ -1,2 +1,9 @@ -export type { DBSchemaBuilder, FieldSchemaBuilder, TableMap } from './core'; +export type { + DBSchemaBuilder, + FieldSchemaBuilder, + ORMClient, + Table, + TableMap, + TableSchemaBuilder, +} from './core'; export { createORMClient, f, YjsDBAdapter } from './core'; diff --git a/packages/frontend/core/src/modules/editor-settting/impls/user-db.ts b/packages/frontend/core/src/modules/editor-settting/impls/user-db.ts new file mode 100644 index 0000000000..2c12704f98 --- /dev/null +++ b/packages/frontend/core/src/modules/editor-settting/impls/user-db.ts @@ -0,0 +1,84 @@ +import type { GlobalState } from '@toeverything/infra'; +import { Service } from '@toeverything/infra'; +import { map, type Observable, switchMap } from 'rxjs'; + +import type { UserDBService } from '../../userspace'; +import type { EditorSettingProvider } from '../provider/editor-setting-provider'; + +export class CurrentUserDBEditorSettingProvider + extends Service + implements EditorSettingProvider +{ + currentUserDB$ = this.userDBService.currentUserDB.db$; + fallback = new GlobalStateEditorSettingProvider(this.globalState); + + constructor( + public readonly userDBService: UserDBService, + public readonly globalState: GlobalState + ) { + super(); + } + + set(key: string, value: string): void { + if (this.currentUserDB$.value) { + this.currentUserDB$.value?.editorSetting.create({ + key, + value, + }); + } else { + this.fallback.set(key, value); + } + } + + get(key: string): string | undefined { + if (this.currentUserDB$.value) { + return this.currentUserDB$.value?.editorSetting.get(key)?.value; + } else { + return this.fallback.get(key); + } + } + + watchAll(): Observable> { + return this.currentUserDB$.pipe( + switchMap(db => { + if (db) { + return db.editorSetting.find$().pipe( + map(settings => { + return settings.reduce( + (acc, setting) => { + acc[setting.key] = setting.value; + return acc; + }, + {} as Record + ); + }) + ); + } else { + return this.fallback.watchAll(); + } + }) + ); + } +} + +const storageKey = 'editor-setting'; + +class GlobalStateEditorSettingProvider implements EditorSettingProvider { + constructor(public readonly globalState: GlobalState) {} + set(key: string, value: string): void { + const all = this.globalState.get>(storageKey) ?? {}; + const after = { + ...all, + [key]: value, + }; + this.globalState.set(storageKey, after); + } + get(key: string): string | undefined { + return this.globalState.get>(storageKey)?.[key]; + } + watchAll(): Observable> { + return this.globalState + .watch>(storageKey) + .pipe(map(all => all ?? {})); + } +} diff --git a/packages/frontend/core/src/modules/editor-settting/index.ts b/packages/frontend/core/src/modules/editor-settting/index.ts index 575fc6ecef..68a36e55bb 100644 --- a/packages/frontend/core/src/modules/editor-settting/index.ts +++ b/packages/frontend/core/src/modules/editor-settting/index.ts @@ -1,7 +1,8 @@ import { type Framework, GlobalState } from '@toeverything/infra'; +import { UserDBService } from '../userspace'; import { EditorSetting } from './entities/editor-setting'; -import { GlobalStateEditorSettingProvider } from './impls/global-state'; +import { CurrentUserDBEditorSettingProvider } from './impls/user-db'; import { EditorSettingProvider } from './provider/editor-setting-provider'; import { EditorSettingService } from './services/editor-setting'; export type { FontFamily } from './schema'; @@ -12,7 +13,8 @@ export function configureEditorSettingModule(framework: Framework) { framework .service(EditorSettingService) .entity(EditorSetting, [EditorSettingProvider]) - .impl(EditorSettingProvider, GlobalStateEditorSettingProvider, [ + .impl(EditorSettingProvider, CurrentUserDBEditorSettingProvider, [ + UserDBService, GlobalState, ]); } diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index d2b260e72b..aa15481b65 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -24,6 +24,7 @@ import { configureSystemFontFamilyModule } from './system-font-family'; import { configureTagModule } from './tag'; import { configureTelemetryModule } from './telemetry'; import { configureThemeEditorModule } from './theme-editor'; +import { configureUserspaceModule } from './userspace'; export function configureCommonModules(framework: Framework) { configureInfraModules(framework); @@ -51,4 +52,5 @@ export function configureCommonModules(framework: Framework) { configureEditorSettingModule(framework); configureImportTemplateModule(framework); configureCreateWorkspaceModule(framework); + configureUserspaceModule(framework); } diff --git a/packages/frontend/core/src/modules/userspace/entities/current-user-db.ts b/packages/frontend/core/src/modules/userspace/entities/current-user-db.ts new file mode 100644 index 0000000000..b60ee4c880 --- /dev/null +++ b/packages/frontend/core/src/modules/userspace/entities/current-user-db.ts @@ -0,0 +1,34 @@ +import { Entity, LiveData } from '@toeverything/infra'; +import { finalize, of, switchMap } from 'rxjs'; + +import type { AuthService } from '../../cloud'; +import type { UserspaceService } from '../services/userspace'; + +export class CurrentUserDB extends Entity { + constructor( + private readonly userDBService: UserspaceService, + private readonly authService: AuthService + ) { + super(); + } + + db$ = LiveData.from( + this.authService.session.account$ + .selector(a => a?.id) + .pipe( + switchMap(userId => { + if (userId) { + const ref = this.userDBService.openDB(userId); + return of(ref.obj).pipe( + finalize(() => { + ref.release(); + }) + ); + } else { + return of(null); + } + }) + ), + null + ); +} diff --git a/packages/frontend/core/src/modules/userspace/entities/user-db-engine.ts b/packages/frontend/core/src/modules/userspace/entities/user-db-engine.ts new file mode 100644 index 0000000000..0023761273 --- /dev/null +++ b/packages/frontend/core/src/modules/userspace/entities/user-db-engine.ts @@ -0,0 +1,34 @@ +import { DocEngine, Entity } from '@toeverything/infra'; + +import type { WebSocketService } from '../../cloud'; +import { UserDBDocServer } from '../impls/user-db-doc-server'; +import type { UserspaceStorageProvider } from '../provider/storage'; + +export class UserDBEngine extends Entity<{ + userId: string; +}> { + private readonly userId = this.props.userId; + private readonly socket = this.websocketService.newSocket(); + readonly docEngine = new DocEngine( + this.userspaceStorageProvider.getDocStorage('affine-cloud:' + this.userId), + new UserDBDocServer(this.userId, this.socket) + ); + + canGracefulStop() { + // TODO(@eyhn): Implement this + return true; + } + + constructor( + private readonly userspaceStorageProvider: UserspaceStorageProvider, + private readonly websocketService: WebSocketService + ) { + super(); + this.docEngine.start(); + } + + override dispose() { + this.docEngine.stop(); + this.socket.close(); + } +} diff --git a/packages/frontend/core/src/modules/userspace/entities/user-db-table.ts b/packages/frontend/core/src/modules/userspace/entities/user-db-table.ts new file mode 100644 index 0000000000..c8d8d58830 --- /dev/null +++ b/packages/frontend/core/src/modules/userspace/entities/user-db-table.ts @@ -0,0 +1,35 @@ +import type { + Table as OrmTable, + TableSchemaBuilder, +} from '@toeverything/infra'; +import { Entity } from '@toeverything/infra'; + +import type { UserDBEngine } from './user-db-engine'; + +export class UserDBTable extends Entity<{ + table: OrmTable; + storageDocId: string; + engine: UserDBEngine; +}> { + readonly table = this.props.table; + readonly docEngine = this.props.engine.docEngine; + + isSyncing$ = this.docEngine + .docState$(this.props.storageDocId) + .map(docState => docState.syncing); + + isLoading$ = this.docEngine + .docState$(this.props.storageDocId) + .map(docState => docState.loading); + + create: typeof this.table.create = this.table.create.bind(this.table); + update: typeof this.table.update = this.table.update.bind(this.table); + get: typeof this.table.get = this.table.get.bind(this.table); + // eslint-disable-next-line rxjs/finnish + get$: typeof this.table.get$ = this.table.get$.bind(this.table); + find: typeof this.table.find = this.table.find.bind(this.table); + // eslint-disable-next-line rxjs/finnish + find$: typeof this.table.find$ = this.table.find$.bind(this.table); + keys: typeof this.table.keys = this.table.keys.bind(this.table); + delete: typeof this.table.delete = this.table.delete.bind(this.table); +} diff --git a/packages/frontend/core/src/modules/userspace/entities/user-db.ts b/packages/frontend/core/src/modules/userspace/entities/user-db.ts new file mode 100644 index 0000000000..f8b68d9c8a --- /dev/null +++ b/packages/frontend/core/src/modules/userspace/entities/user-db.ts @@ -0,0 +1,46 @@ +import { createORMClient, Entity, YjsDBAdapter } from '@toeverything/infra'; +import { Doc as YDoc } from 'yjs'; + +import { USER_DB_SCHEMA } from '../schema'; +import { UserDBEngine } from './user-db-engine'; +import { UserDBTable } from './user-db-table'; + +const UserDBClient = createORMClient(USER_DB_SCHEMA); + +export class UserDB extends Entity<{ + userId: string; +}> { + readonly engine = this.framework.createEntity(UserDBEngine, { + userId: this.props.userId, + }); + readonly db = new UserDBClient( + new YjsDBAdapter(USER_DB_SCHEMA, { + getDoc: guid => { + const ydoc = new YDoc({ + guid, + }); + this.engine.docEngine.addDoc(ydoc, false); + this.engine.docEngine.setPriority(ydoc.guid, 50); + return ydoc; + }, + }) + ); + + constructor() { + super(); + Object.entries(USER_DB_SCHEMA).forEach(([tableName]) => { + const table = this.framework.createEntity(UserDBTable, { + table: this.db[tableName as keyof typeof USER_DB_SCHEMA], + storageDocId: tableName, + engine: this.engine, + }); + Object.defineProperty(this, tableName, { + get: () => table, + }); + }); + } +} + +export type UserDBWithTables = UserDB & { + [K in keyof USER_DB_SCHEMA]: UserDBTable; +}; diff --git a/packages/frontend/core/src/modules/userspace/impls/indexeddb-storage.ts b/packages/frontend/core/src/modules/userspace/impls/indexeddb-storage.ts new file mode 100644 index 0000000000..929bc3e7d8 --- /dev/null +++ b/packages/frontend/core/src/modules/userspace/impls/indexeddb-storage.ts @@ -0,0 +1,266 @@ +import type { + ByteKV, + ByteKVBehavior, + DocEvent, + DocEventBus, + DocStorage, +} from '@toeverything/infra'; +import type { DBSchema, IDBPDatabase, IDBPObjectStore } from 'idb'; +import { openDB } from 'idb'; +import { mergeUpdates } from 'yjs'; + +class BroadcastChannelDocEventBus implements DocEventBus { + senderChannel = new BroadcastChannel('user-db:' + this.userId); + constructor(private readonly userId: string) {} + emit(event: DocEvent): void { + this.senderChannel.postMessage(event); + } + + on(cb: (event: DocEvent) => void): () => void { + const listener = (event: MessageEvent) => { + cb(event.data); + }; + const channel = new BroadcastChannel('user-db:' + this.userId); + channel.addEventListener('message', listener); + return () => { + channel.removeEventListener('message', listener); + channel.close(); + }; + } +} + +function isEmptyUpdate(binary: Uint8Array) { + return ( + binary.byteLength === 0 || + (binary.byteLength === 2 && binary[0] === 0 && binary[1] === 0) + ); +} + +export class IndexedDBUserspaceDocStorage implements DocStorage { + constructor(private readonly userId: string) {} + eventBus = new BroadcastChannelDocEventBus(this.userId); + readonly doc = new Doc(this.userId); + readonly syncMetadata = new KV(`affine-cloud:${this.userId}:sync-metadata`); + readonly serverClock = new KV(`affine-cloud:${this.userId}:server-clock`); +} + +interface DocDBSchema extends DBSchema { + userspace: { + key: string; + value: { + id: string; + updates: { + timestamp: number; + update: Uint8Array; + }[]; + }; + }; +} + +type DocType = DocStorage['doc']; +class Doc implements DocType { + dbName = 'affine-cloud:' + this.userId + ':doc'; + dbPromise: Promise> | null = null; + dbVersion = 1; + + constructor(private readonly userId: string) {} + + upgradeDB(db: IDBPDatabase) { + db.createObjectStore('userspace', { keyPath: 'id' }); + } + + getDb() { + if (this.dbPromise === null) { + this.dbPromise = openDB(this.dbName, this.dbVersion, { + upgrade: db => this.upgradeDB(db), + }); + } + return this.dbPromise; + } + + async get(docId: string): Promise { + const db = await this.getDb(); + const store = db + .transaction('userspace', 'readonly') + .objectStore('userspace'); + const data = await store.get(docId); + + if (!data) { + return null; + } + + const updates = data.updates + .map(({ update }) => update) + .filter(update => !isEmptyUpdate(update)); + const update = updates.length > 0 ? mergeUpdates(updates) : null; + + return update; + } + + async set(docId: string, data: Uint8Array) { + const db = await this.getDb(); + const store = db + .transaction('userspace', 'readwrite') + .objectStore('userspace'); + + const rows = [{ timestamp: Date.now(), update: data }]; + await store.put({ + id: docId, + updates: rows, + }); + } + + async keys() { + const db = await this.getDb(); + const store = db + .transaction('userspace', 'readonly') + .objectStore('userspace'); + + return store.getAllKeys(); + } + + clear(): void | Promise { + return; + } + + del(_key: string): void | Promise { + return; + } + + async transaction( + cb: (transaction: ByteKVBehavior) => Promise + ): Promise { + const db = await this.getDb(); + const store = db + .transaction('userspace', 'readwrite') + .objectStore('userspace'); + return await cb({ + async get(docId) { + const data = await store.get(docId); + + if (!data) { + return null; + } + + const { updates } = data; + const update = mergeUpdates(updates.map(({ update }) => update)); + + return update; + }, + keys() { + return store.getAllKeys(); + }, + async set(docId, data) { + const rows = [{ timestamp: Date.now(), update: data }]; + await store.put({ + id: docId, + updates: rows, + }); + }, + async clear() { + return await store.clear(); + }, + async del(key) { + return store.delete(key); + }, + }); + } +} + +interface KvDBSchema extends DBSchema { + kv: { + key: string; + value: { key: string; val: Uint8Array }; + }; +} + +class KV implements ByteKV { + constructor(private readonly dbName: string) {} + + dbPromise: Promise> | null = null; + dbVersion = 1; + + upgradeDB(db: IDBPDatabase) { + db.createObjectStore('kv', { keyPath: 'key' }); + } + + getDb() { + if (this.dbPromise === null) { + this.dbPromise = openDB(this.dbName, this.dbVersion, { + upgrade: db => this.upgradeDB(db), + }); + } + return this.dbPromise; + } + + async transaction( + cb: (transaction: ByteKVBehavior) => Promise + ): Promise { + const db = await this.getDb(); + const store = db.transaction('kv', 'readwrite').objectStore('kv'); + + const behavior = new KVBehavior(store); + return await cb(behavior); + } + + async get(key: string): Promise { + const db = await this.getDb(); + const store = db.transaction('kv', 'readonly').objectStore('kv'); + return new KVBehavior(store).get(key); + } + async set(key: string, value: Uint8Array): Promise { + const db = await this.getDb(); + const store = db.transaction('kv', 'readwrite').objectStore('kv'); + return new KVBehavior(store).set(key, value); + } + async keys(): Promise { + const db = await this.getDb(); + const store = db.transaction('kv', 'readwrite').objectStore('kv'); + return new KVBehavior(store).keys(); + } + async clear() { + const db = await this.getDb(); + const store = db.transaction('kv', 'readwrite').objectStore('kv'); + return new KVBehavior(store).clear(); + } + async del(key: string) { + const db = await this.getDb(); + const store = db.transaction('kv', 'readwrite').objectStore('kv'); + return new KVBehavior(store).del(key); + } +} + +class KVBehavior implements ByteKVBehavior { + constructor( + private readonly store: IDBPObjectStore + ) {} + async get(key: string): Promise { + const value = await this.store.get(key); + return value?.val ?? null; + } + async set(key: string, value: Uint8Array): Promise { + if (this.store.put === undefined) { + throw new Error('Cannot set in a readonly transaction'); + } + await this.store.put({ + key: key, + val: value, + }); + } + async keys(): Promise { + return await this.store.getAllKeys(); + } + async del(key: string) { + if (this.store.delete === undefined) { + throw new Error('Cannot set in a readonly transaction'); + } + return await this.store.delete(key); + } + + async clear() { + if (this.store.clear === undefined) { + throw new Error('Cannot set in a readonly transaction'); + } + return await this.store.clear(); + } +} diff --git a/packages/frontend/core/src/modules/userspace/impls/sqlite-storage.ts b/packages/frontend/core/src/modules/userspace/impls/sqlite-storage.ts new file mode 100644 index 0000000000..cd239e309f --- /dev/null +++ b/packages/frontend/core/src/modules/userspace/impls/sqlite-storage.ts @@ -0,0 +1,185 @@ +import { apis } from '@affine/electron-api'; +import type { + ByteKV, + ByteKVBehavior, + DocEvent, + DocEventBus, + DocStorage, +} from '@toeverything/infra'; +import { AsyncLock } from '@toeverything/infra'; + +class BroadcastChannelDocEventBus implements DocEventBus { + senderChannel = new BroadcastChannel('user-db:' + this.userId); + constructor(private readonly userId: string) {} + emit(event: DocEvent): void { + this.senderChannel.postMessage(event); + } + + on(cb: (event: DocEvent) => void): () => void { + const listener = (event: MessageEvent) => { + cb(event.data); + }; + const channel = new BroadcastChannel('user-db:' + this.userId); + channel.addEventListener('message', listener); + return () => { + channel.removeEventListener('message', listener); + channel.close(); + }; + } +} + +export class SqliteUserspaceDocStorage implements DocStorage { + constructor(private readonly userId: string) {} + eventBus = new BroadcastChannelDocEventBus(this.userId); + readonly doc = new Doc(this.userId); + readonly syncMetadata = new SyncMetadataKV(this.userId); + readonly serverClock = new ServerClockKV(this.userId); +} + +type DocType = DocStorage['doc']; + +class Doc implements DocType { + lock = new AsyncLock(); + constructor(private readonly userId: string) { + if (!apis?.db) { + throw new Error('sqlite datasource is not available'); + } + } + + async transaction( + cb: (transaction: ByteKVBehavior) => Promise + ): Promise { + using _lock = await this.lock.acquire(); + return await cb(this); + } + + keys(): string[] | Promise { + return []; + } + + async get(docId: string) { + if (!apis?.db) { + throw new Error('sqlite datasource is not available'); + } + const update = await apis.db.getDocAsUpdates( + 'userspace', + this.userId, + docId + ); + + if (update) { + if ( + update.byteLength === 0 || + (update.byteLength === 2 && update[0] === 0 && update[1] === 0) + ) { + return null; + } + + return update; + } + + return null; + } + + async set(docId: string, data: Uint8Array) { + if (!apis?.db) { + throw new Error('sqlite datasource is not available'); + } + await apis.db.applyDocUpdate('userspace', this.userId, data, docId); + } + + clear(): void | Promise { + return; + } + + async del(docId: string) { + if (!apis?.db) { + throw new Error('sqlite datasource is not available'); + } + await apis.db.deleteDoc('userspace', this.userId, docId); + } +} + +class SyncMetadataKV implements ByteKV { + constructor(private readonly userId: string) {} + transaction(cb: (behavior: ByteKVBehavior) => Promise): Promise { + return cb(this); + } + + get(key: string): Uint8Array | null | Promise { + if (!apis?.db) { + throw new Error('sqlite datasource is not available'); + } + return apis.db.getSyncMetadata('userspace', this.userId, key); + } + + set(key: string, data: Uint8Array): void | Promise { + if (!apis?.db) { + throw new Error('sqlite datasource is not available'); + } + return apis.db.setSyncMetadata('userspace', this.userId, key, data); + } + + keys(): string[] | Promise { + if (!apis?.db) { + throw new Error('sqlite datasource is not available'); + } + return apis.db.getSyncMetadataKeys('userspace', this.userId); + } + + del(key: string): void | Promise { + if (!apis?.db) { + throw new Error('sqlite datasource is not available'); + } + return apis.db.delSyncMetadata('userspace', this.userId, key); + } + + clear(): void | Promise { + if (!apis?.db) { + throw new Error('sqlite datasource is not available'); + } + return apis.db.clearSyncMetadata('userspace', this.userId); + } +} + +class ServerClockKV implements ByteKV { + constructor(private readonly userId: string) {} + transaction(cb: (behavior: ByteKVBehavior) => Promise): Promise { + return cb(this); + } + + get(key: string): Uint8Array | null | Promise { + if (!apis?.db) { + throw new Error('sqlite datasource is not available'); + } + return apis.db.getServerClock('userspace', this.userId, key); + } + + set(key: string, data: Uint8Array): void | Promise { + if (!apis?.db) { + throw new Error('sqlite datasource is not available'); + } + return apis.db.setServerClock('userspace', this.userId, key, data); + } + + keys(): string[] | Promise { + if (!apis?.db) { + throw new Error('sqlite datasource is not available'); + } + return apis.db.getServerClockKeys('userspace', this.userId); + } + + del(key: string): void | Promise { + if (!apis?.db) { + throw new Error('sqlite datasource is not available'); + } + return apis.db.delServerClock('userspace', this.userId, key); + } + + clear(): void | Promise { + if (!apis?.db) { + throw new Error('sqlite datasource is not available'); + } + return apis.db.clearServerClock('userspace', this.userId); + } +} diff --git a/packages/frontend/core/src/modules/userspace/impls/user-db-doc-server.ts b/packages/frontend/core/src/modules/userspace/impls/user-db-doc-server.ts new file mode 100644 index 0000000000..18fa89bd75 --- /dev/null +++ b/packages/frontend/core/src/modules/userspace/impls/user-db-doc-server.ts @@ -0,0 +1,195 @@ +import { DebugLogger } from '@affine/debug'; +import { + ErrorNames, + UserFriendlyError, + type UserFriendlyErrorResponse, +} from '@affine/graphql'; +import { type DocServer, throwIfAborted } from '@toeverything/infra'; +import type { Socket } from 'socket.io-client'; + +import { + base64ToUint8Array, + uint8ArrayToBase64, +} from '../../workspace-engine/utils/base64'; + +type WebsocketResponse = { error: UserFriendlyErrorResponse } | { data: T }; +const logger = new DebugLogger('affine-cloud-doc-engine-server'); + +export class UserDBDocServer implements DocServer { + interruptCb: ((reason: string) => void) | null = null; + SEND_TIMEOUT = 30000; + + constructor( + private readonly userId: string, + private readonly socket: Socket + ) {} + + private async clientHandShake() { + await this.socket.emitWithAck('space:join', { + spaceType: 'userspace', + spaceId: this.userId, + clientVersion: runtimeConfig.appVersion, + }); + } + + async pullDoc(docId: string, state: Uint8Array) { + // for testing + await (window as any)._TEST_SIMULATE_SYNC_LAG; + + const stateVector = state ? await uint8ArrayToBase64(state) : undefined; + + const response: WebsocketResponse<{ + missing: string; + state: string; + timestamp: number; + }> = await this.socket + .timeout(this.SEND_TIMEOUT) + .emitWithAck('space:load-doc', { + spaceType: 'userspace', + spaceId: this.userId, + docId: docId, + stateVector, + }); + + if ('error' in response) { + const error = new UserFriendlyError(response.error); + if (error.name === ErrorNames.DOC_NOT_FOUND) { + return null; + } else { + throw error; + } + } else { + return { + data: base64ToUint8Array(response.data.missing), + stateVector: response.data.state + ? base64ToUint8Array(response.data.state) + : undefined, + serverClock: response.data.timestamp, + }; + } + } + async pushDoc(docId: string, data: Uint8Array) { + const payload = await uint8ArrayToBase64(data); + + const response: WebsocketResponse<{ timestamp: number }> = await this.socket + .timeout(this.SEND_TIMEOUT) + .emitWithAck('space:push-doc-updates', { + spaceType: 'userspace', + spaceId: this.userId, + docId: docId, + updates: [payload], + }); + + if ('error' in response) { + logger.error('client-update-v2 error', { + userId: this.userId, + guid: docId, + response, + }); + + throw new UserFriendlyError(response.error); + } + + return { serverClock: response.data.timestamp }; + } + async loadServerClock(after: number): Promise> { + const response: WebsocketResponse> = + await this.socket + .timeout(this.SEND_TIMEOUT) + .emitWithAck('space:load-doc-timestamps', { + spaceType: 'userspace', + spaceId: this.userId, + timestamp: after, + }); + + if ('error' in response) { + logger.error('client-pre-sync error', { + workspaceId: this.userId, + response, + }); + + throw new UserFriendlyError(response.error); + } + + return new Map(Object.entries(response.data)); + } + async subscribeAllDocs( + cb: (updates: { + docId: string; + data: Uint8Array; + serverClock: number; + }) => void + ): Promise<() => void> { + const handleUpdate = async (message: { + spaceType: string; + spaceId: string; + docId: string; + updates: string[]; + timestamp: number; + }) => { + if ( + message.spaceType === 'userspace' && + message.spaceId === this.userId + ) { + message.updates.forEach(update => { + cb({ + docId: message.docId, + data: base64ToUint8Array(update), + serverClock: message.timestamp, + }); + }); + } + }; + this.socket.on('space:broadcast-doc-updates', handleUpdate); + + return () => { + this.socket.off('space:broadcast-doc-updates', handleUpdate); + }; + } + async waitForConnectingServer(signal: AbortSignal): Promise { + this.socket.on('server-version-rejected', this.handleVersionRejected); + this.socket.on('disconnect', this.handleDisconnect); + + throwIfAborted(signal); + if (this.socket.connected) { + await this.clientHandShake(); + } else { + this.socket.connect(); + await new Promise((resolve, reject) => { + this.socket.on('connect', () => { + resolve(); + }); + signal.addEventListener('abort', () => { + reject('aborted'); + }); + }); + throwIfAborted(signal); + await this.clientHandShake(); + } + } + disconnectServer(): void { + if (!this.socket) { + return; + } + + this.socket.emit('space:leave', { + spaceType: 'userspace', + spaceId: this.userId, + }); + this.socket.off('server-version-rejected', this.handleVersionRejected); + this.socket.off('disconnect', this.handleDisconnect); + this.socket.disconnect(); + } + onInterrupted = (cb: (reason: string) => void) => { + this.interruptCb = cb; + }; + handleInterrupted = (reason: string) => { + this.interruptCb?.(reason); + }; + handleDisconnect = (reason: Socket.DisconnectReason) => { + this.interruptCb?.(reason); + }; + handleVersionRejected = () => { + this.interruptCb?.('Client version rejected'); + }; +} diff --git a/packages/frontend/core/src/modules/userspace/index.ts b/packages/frontend/core/src/modules/userspace/index.ts new file mode 100644 index 0000000000..751073e57b --- /dev/null +++ b/packages/frontend/core/src/modules/userspace/index.ts @@ -0,0 +1,40 @@ +export { UserspaceService as UserDBService } from './services/userspace'; + +import type { Framework } from '@toeverything/infra'; + +import { AuthService, WebSocketService } from '../cloud'; +import { CurrentUserDB } from './entities/current-user-db'; +import { UserDB } from './entities/user-db'; +import { UserDBEngine } from './entities/user-db-engine'; +import { UserDBTable } from './entities/user-db-table'; +import { IndexedDBUserspaceDocStorage } from './impls/indexeddb-storage'; +import { SqliteUserspaceDocStorage } from './impls/sqlite-storage'; +import { UserspaceStorageProvider } from './provider/storage'; +import { UserspaceService } from './services/userspace'; + +export function configureUserspaceModule(framework: Framework) { + framework + .service(UserspaceService) + .entity(CurrentUserDB, [UserspaceService, AuthService]) + .entity(UserDB) + .entity(UserDBTable) + .entity(UserDBEngine, [UserspaceStorageProvider, WebSocketService]); +} + +export function configureIndexedDBUserspaceStorageProvider( + framework: Framework +) { + framework.impl(UserspaceStorageProvider, { + getDocStorage(userId: string) { + return new IndexedDBUserspaceDocStorage(userId); + }, + }); +} + +export function configureSqliteUserspaceStorageProvider(framework: Framework) { + framework.impl(UserspaceStorageProvider, { + getDocStorage(userId: string) { + return new SqliteUserspaceDocStorage(userId); + }, + }); +} diff --git a/packages/frontend/core/src/modules/userspace/provider/storage.ts b/packages/frontend/core/src/modules/userspace/provider/storage.ts new file mode 100644 index 0000000000..5fb6c88a7a --- /dev/null +++ b/packages/frontend/core/src/modules/userspace/provider/storage.ts @@ -0,0 +1,8 @@ +import { createIdentifier, type DocStorage } from '@toeverything/infra'; + +export interface UserspaceStorageProvider { + getDocStorage(userId: string): DocStorage; +} + +export const UserspaceStorageProvider = + createIdentifier('UserspaceStorageProvider'); diff --git a/packages/frontend/core/src/modules/userspace/schema/index.ts b/packages/frontend/core/src/modules/userspace/schema/index.ts new file mode 100644 index 0000000000..f773ec1e4e --- /dev/null +++ b/packages/frontend/core/src/modules/userspace/schema/index.ts @@ -0,0 +1,9 @@ +import { type DBSchemaBuilder, f } from '@toeverything/infra'; + +export const USER_DB_SCHEMA = { + editorSetting: { + key: f.string().primaryKey(), + value: f.string(), + }, +} as const satisfies DBSchemaBuilder; +export type USER_DB_SCHEMA = typeof USER_DB_SCHEMA; diff --git a/packages/frontend/core/src/modules/userspace/services/userspace.ts b/packages/frontend/core/src/modules/userspace/services/userspace.ts new file mode 100644 index 0000000000..490086d5d8 --- /dev/null +++ b/packages/frontend/core/src/modules/userspace/services/userspace.ts @@ -0,0 +1,35 @@ +import { ObjectPool, Service } from '@toeverything/infra'; + +import { CurrentUserDB } from '../entities/current-user-db'; +import { UserDB, type UserDBWithTables } from '../entities/user-db'; + +export class UserspaceService extends Service { + pool = new ObjectPool({ + onDelete(obj) { + obj.dispose(); + }, + onDangling(obj) { + return obj.engine.canGracefulStop(); + }, + }); + + private _currentUserDB: CurrentUserDB | null = null; + + get currentUserDB() { + if (!this._currentUserDB) { + this._currentUserDB = this.framework.createEntity(CurrentUserDB); + } + return this._currentUserDB; + } + + openDB(userId: string) { + const exists = this.pool.get(userId); + if (exists) { + return exists; + } + const db = this.framework.createEntity(UserDB, { + userId, + }) as UserDBWithTables; + return this.pool.put(userId, db); + } +} diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-sqlite.ts b/packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-sqlite.ts index e3506dd762..b7f39999b2 100644 --- a/packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-sqlite.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/engine/blob-sqlite.ts @@ -10,7 +10,7 @@ export class SqliteBlobStorage implements BlobStorage { readonly = false; async get(key: string) { assertExists(apis); - const buffer = await apis.db.getBlob(this.workspaceId, key); + const buffer = await apis.db.getBlob('workspace', this.workspaceId, key); if (buffer) { return bufferToBlob(buffer); } @@ -19,6 +19,7 @@ export class SqliteBlobStorage implements BlobStorage { async set(key: string, value: Blob) { assertExists(apis); await apis.db.addBlob( + 'workspace', this.workspaceId, key, new Uint8Array(await value.arrayBuffer()) @@ -27,10 +28,10 @@ export class SqliteBlobStorage implements BlobStorage { } delete(key: string) { assertExists(apis); - return apis.db.deleteBlob(this.workspaceId, key); + return apis.db.deleteBlob('workspace', this.workspaceId, key); } list() { assertExists(apis); - return apis.db.getBlobKeys(this.workspaceId); + return apis.db.getBlobKeys('workspace', this.workspaceId); } } diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-sqlite.ts b/packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-sqlite.ts index 390255274f..0ee1aeb0fc 100644 --- a/packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-sqlite.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/engine/doc-sqlite.ts @@ -37,7 +37,11 @@ class Doc implements DocType { if (!apis?.db) { throw new Error('sqlite datasource is not available'); } - const update = await apis.db.getDocAsUpdates(this.workspaceId, docId); + const update = await apis.db.getDocAsUpdates( + 'workspace', + this.workspaceId, + docId + ); if (update) { if ( @@ -57,7 +61,7 @@ class Doc implements DocType { if (!apis?.db) { throw new Error('sqlite datasource is not available'); } - await apis.db.applyDocUpdate(this.workspaceId, data, docId); + await apis.db.applyDocUpdate('workspace', this.workspaceId, data, docId); } clear(): void | Promise { @@ -68,7 +72,7 @@ class Doc implements DocType { if (!apis?.db) { throw new Error('sqlite datasource is not available'); } - await apis.db.deleteDoc(this.workspaceId, docId); + await apis.db.deleteDoc('workspace', this.workspaceId, docId); } } @@ -82,35 +86,35 @@ class SyncMetadataKV implements ByteKV { if (!apis?.db) { throw new Error('sqlite datasource is not available'); } - return apis.db.getSyncMetadata(this.workspaceId, key); + return apis.db.getSyncMetadata('workspace', this.workspaceId, key); } set(key: string, data: Uint8Array): void | Promise { if (!apis?.db) { throw new Error('sqlite datasource is not available'); } - return apis.db.setSyncMetadata(this.workspaceId, key, data); + return apis.db.setSyncMetadata('workspace', this.workspaceId, key, data); } keys(): string[] | Promise { if (!apis?.db) { throw new Error('sqlite datasource is not available'); } - return apis.db.getSyncMetadataKeys(this.workspaceId); + return apis.db.getSyncMetadataKeys('workspace', this.workspaceId); } del(key: string): void | Promise { if (!apis?.db) { throw new Error('sqlite datasource is not available'); } - return apis.db.delSyncMetadata(this.workspaceId, key); + return apis.db.delSyncMetadata('workspace', this.workspaceId, key); } clear(): void | Promise { if (!apis?.db) { throw new Error('sqlite datasource is not available'); } - return apis.db.clearSyncMetadata(this.workspaceId); + return apis.db.clearSyncMetadata('workspace', this.workspaceId); } } @@ -124,34 +128,34 @@ class ServerClockKV implements ByteKV { if (!apis?.db) { throw new Error('sqlite datasource is not available'); } - return apis.db.getServerClock(this.workspaceId, key); + return apis.db.getServerClock('workspace', this.workspaceId, key); } set(key: string, data: Uint8Array): void | Promise { if (!apis?.db) { throw new Error('sqlite datasource is not available'); } - return apis.db.setServerClock(this.workspaceId, key, data); + return apis.db.setServerClock('workspace', this.workspaceId, key, data); } keys(): string[] | Promise { if (!apis?.db) { throw new Error('sqlite datasource is not available'); } - return apis.db.getServerClockKeys(this.workspaceId); + return apis.db.getServerClockKeys('workspace', this.workspaceId); } del(key: string): void | Promise { if (!apis?.db) { throw new Error('sqlite datasource is not available'); } - return apis.db.delServerClock(this.workspaceId, key); + return apis.db.delServerClock('workspace', this.workspaceId, key); } clear(): void | Promise { if (!apis?.db) { throw new Error('sqlite datasource is not available'); } - return apis.db.clearServerClock(this.workspaceId); + return apis.db.clearServerClock('workspace', this.workspaceId); } } diff --git a/packages/frontend/electron/renderer/app.tsx b/packages/frontend/electron/renderer/app.tsx index 59d1f1c27e..9d94b1d951 100644 --- a/packages/frontend/electron/renderer/app.tsx +++ b/packages/frontend/electron/renderer/app.tsx @@ -8,6 +8,7 @@ import { configureCommonModules } from '@affine/core/modules'; import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header'; import { configureElectronStateStorageImpls } from '@affine/core/modules/storage'; import { CustomThemeModifier } from '@affine/core/modules/theme-editor'; +import { configureSqliteUserspaceStorageProvider } from '@affine/core/modules/userspace'; import { configureDesktopWorkbenchModule } from '@affine/core/modules/workbench'; import { configureBrowserWorkspaceFlavours, @@ -88,6 +89,7 @@ configureCommonModules(framework); configureElectronStateStorageImpls(framework); configureBrowserWorkspaceFlavours(framework); configureSqliteWorkspaceEngineStorageProvider(framework); +configureSqliteUserspaceStorageProvider(framework); configureDesktopWorkbenchModule(framework); configureAppTabsHeaderModule(framework); const frameworkProvider = framework.provider(); diff --git a/packages/frontend/electron/src/helper/db/ensure-db.ts b/packages/frontend/electron/src/helper/db/ensure-db.ts index a75c5add59..82488d06ec 100644 --- a/packages/frontend/electron/src/helper/db/ensure-db.ts +++ b/packages/frontend/electron/src/helper/db/ensure-db.ts @@ -1,18 +1,23 @@ import { logger } from '../logger'; +import type { SpaceType } from './types'; import type { WorkspaceSQLiteDB } from './workspace-db-adapter'; import { openWorkspaceDatabase } from './workspace-db-adapter'; // export for testing -export const db$Map = new Map>(); +export const db$Map = new Map< + `${SpaceType}:${string}`, + Promise +>(); -async function getWorkspaceDB(id: string) { - let db = await db$Map.get(id); - if (!db$Map.has(id)) { - const promise = openWorkspaceDatabase(id); - db$Map.set(id, promise); +async function getWorkspaceDB(spaceType: SpaceType, id: string) { + const cacheId = `${spaceType}:${id}` as const; + let db = await db$Map.get(cacheId); + if (!db) { + const promise = openWorkspaceDatabase(spaceType, id); + db$Map.set(cacheId, promise); const _db = (db = await promise); const cleanup = () => { - db$Map.delete(id); + db$Map.delete(cacheId); _db .destroy() .then(() => { @@ -33,6 +38,6 @@ async function getWorkspaceDB(id: string) { return db!; } -export function ensureSQLiteDB(id: string) { - return getWorkspaceDB(id); +export function ensureSQLiteDB(spaceType: SpaceType, id: string) { + return getWorkspaceDB(spaceType, id); } diff --git a/packages/frontend/electron/src/helper/db/index.ts b/packages/frontend/electron/src/helper/db/index.ts index f34f08bb89..f59931375a 100644 --- a/packages/frontend/electron/src/helper/db/index.ts +++ b/packages/frontend/electron/src/helper/db/index.ts @@ -1,92 +1,129 @@ import { mainRPC } from '../main-rpc'; import type { MainEventRegister } from '../type'; import { ensureSQLiteDB } from './ensure-db'; +import type { SpaceType } from './types'; export * from './ensure-db'; export const dbHandlers = { - getDocAsUpdates: async (workspaceId: string, subdocId: string) => { - const workspaceDB = await ensureSQLiteDB(workspaceId); - return workspaceDB.getDocAsUpdates(subdocId); + getDocAsUpdates: async ( + spaceType: SpaceType, + workspaceId: string, + subdocId: string + ) => { + const spaceDB = await ensureSQLiteDB(spaceType, workspaceId); + return spaceDB.getDocAsUpdates(subdocId); }, applyDocUpdate: async ( + spaceType: SpaceType, workspaceId: string, update: Uint8Array, subdocId: string ) => { - const workspaceDB = await ensureSQLiteDB(workspaceId); - return workspaceDB.addUpdateToSQLite(update, subdocId); + const spaceDB = await ensureSQLiteDB(spaceType, workspaceId); + return spaceDB.addUpdateToSQLite(update, subdocId); }, - deleteDoc: async (workspaceId: string, subdocId: string) => { - const workspaceDB = await ensureSQLiteDB(workspaceId); - return workspaceDB.deleteUpdate(subdocId); + deleteDoc: async ( + spaceType: SpaceType, + workspaceId: string, + subdocId: string + ) => { + const spaceDB = await ensureSQLiteDB(spaceType, workspaceId); + return spaceDB.deleteUpdate(subdocId); }, - addBlob: async (workspaceId: string, key: string, data: Uint8Array) => { - const workspaceDB = await ensureSQLiteDB(workspaceId); - return workspaceDB.addBlob(key, data); + addBlob: async ( + spaceType: SpaceType, + workspaceId: string, + key: string, + data: Uint8Array + ) => { + const spaceDB = await ensureSQLiteDB(spaceType, workspaceId); + return spaceDB.addBlob(key, data); }, - getBlob: async (workspaceId: string, key: string) => { - const workspaceDB = await ensureSQLiteDB(workspaceId); - return workspaceDB.getBlob(key); + getBlob: async (spaceType: SpaceType, workspaceId: string, key: string) => { + const spaceDB = await ensureSQLiteDB(spaceType, workspaceId); + return spaceDB.getBlob(key); }, - deleteBlob: async (workspaceId: string, key: string) => { - const workspaceDB = await ensureSQLiteDB(workspaceId); - return workspaceDB.deleteBlob(key); + deleteBlob: async ( + spaceType: SpaceType, + workspaceId: string, + key: string + ) => { + const spaceDB = await ensureSQLiteDB(spaceType, workspaceId); + return spaceDB.deleteBlob(key); }, - getBlobKeys: async (workspaceId: string) => { - const workspaceDB = await ensureSQLiteDB(workspaceId); - return workspaceDB.getBlobKeys(); + getBlobKeys: async (spaceType: SpaceType, workspaceId: string) => { + const spaceDB = await ensureSQLiteDB(spaceType, workspaceId); + return spaceDB.getBlobKeys(); }, getDefaultStorageLocation: async () => { return await mainRPC.getPath('sessionData'); }, - getServerClock: async (workspaceId: string, key: string) => { - const workspaceDB = await ensureSQLiteDB(workspaceId); - return workspaceDB.adapter.serverClock.get(key); + getServerClock: async ( + spaceType: SpaceType, + workspaceId: string, + key: string + ) => { + const spaceDB = await ensureSQLiteDB(spaceType, workspaceId); + return spaceDB.adapter.serverClock.get(key); }, setServerClock: async ( + spaceType: SpaceType, workspaceId: string, key: string, data: Uint8Array ) => { - const workspaceDB = await ensureSQLiteDB(workspaceId); - return workspaceDB.adapter.serverClock.set(key, data); + const spaceDB = await ensureSQLiteDB(spaceType, workspaceId); + return spaceDB.adapter.serverClock.set(key, data); }, - getServerClockKeys: async (workspaceId: string) => { - const workspaceDB = await ensureSQLiteDB(workspaceId); - return workspaceDB.adapter.serverClock.keys(); + getServerClockKeys: async (spaceType: SpaceType, workspaceId: string) => { + const spaceDB = await ensureSQLiteDB(spaceType, workspaceId); + return spaceDB.adapter.serverClock.keys(); }, - clearServerClock: async (workspaceId: string) => { - const workspaceDB = await ensureSQLiteDB(workspaceId); - return workspaceDB.adapter.serverClock.clear(); + clearServerClock: async (spaceType: SpaceType, workspaceId: string) => { + const spaceDB = await ensureSQLiteDB(spaceType, workspaceId); + return spaceDB.adapter.serverClock.clear(); }, - delServerClock: async (workspaceId: string, key: string) => { - const workspaceDB = await ensureSQLiteDB(workspaceId); - return workspaceDB.adapter.serverClock.del(key); + delServerClock: async ( + spaceType: SpaceType, + workspaceId: string, + key: string + ) => { + const spaceDB = await ensureSQLiteDB(spaceType, workspaceId); + return spaceDB.adapter.serverClock.del(key); }, - getSyncMetadata: async (workspaceId: string, key: string) => { - const workspaceDB = await ensureSQLiteDB(workspaceId); - return workspaceDB.adapter.syncMetadata.get(key); + getSyncMetadata: async ( + spaceType: SpaceType, + workspaceId: string, + key: string + ) => { + const spaceDB = await ensureSQLiteDB(spaceType, workspaceId); + return spaceDB.adapter.syncMetadata.get(key); }, setSyncMetadata: async ( + spaceType: SpaceType, workspaceId: string, key: string, data: Uint8Array ) => { - const workspaceDB = await ensureSQLiteDB(workspaceId); - return workspaceDB.adapter.syncMetadata.set(key, data); + const spaceDB = await ensureSQLiteDB(spaceType, workspaceId); + return spaceDB.adapter.syncMetadata.set(key, data); }, - getSyncMetadataKeys: async (workspaceId: string) => { - const workspaceDB = await ensureSQLiteDB(workspaceId); - return workspaceDB.adapter.syncMetadata.keys(); + getSyncMetadataKeys: async (spaceType: SpaceType, workspaceId: string) => { + const spaceDB = await ensureSQLiteDB(spaceType, workspaceId); + return spaceDB.adapter.syncMetadata.keys(); }, - clearSyncMetadata: async (workspaceId: string) => { - const workspaceDB = await ensureSQLiteDB(workspaceId); - return workspaceDB.adapter.syncMetadata.clear(); + clearSyncMetadata: async (spaceType: SpaceType, workspaceId: string) => { + const spaceDB = await ensureSQLiteDB(spaceType, workspaceId); + return spaceDB.adapter.syncMetadata.clear(); }, - delSyncMetadata: async (workspaceId: string, key: string) => { - const workspaceDB = await ensureSQLiteDB(workspaceId); - return workspaceDB.adapter.syncMetadata.del(key); + delSyncMetadata: async ( + spaceType: SpaceType, + workspaceId: string, + key: string + ) => { + const spaceDB = await ensureSQLiteDB(spaceType, workspaceId); + return spaceDB.adapter.syncMetadata.del(key); }, }; diff --git a/packages/frontend/electron/src/helper/db/types.ts b/packages/frontend/electron/src/helper/db/types.ts new file mode 100644 index 0000000000..842b92bc10 --- /dev/null +++ b/packages/frontend/electron/src/helper/db/types.ts @@ -0,0 +1 @@ +export type SpaceType = 'userspace' | 'workspace'; diff --git a/packages/frontend/electron/src/helper/db/workspace-db-adapter.ts b/packages/frontend/electron/src/helper/db/workspace-db-adapter.ts index 181e5441b8..daf9155eaa 100644 --- a/packages/frontend/electron/src/helper/db/workspace-db-adapter.ts +++ b/packages/frontend/electron/src/helper/db/workspace-db-adapter.ts @@ -6,6 +6,7 @@ import { logger } from '../logger'; import { getWorkspaceMeta } from '../workspace/meta'; import { SQLiteAdapter } from './db-adapter'; import { mergeUpdate } from './merge-update'; +import type { SpaceType } from './types'; const TRIM_SIZE = 1; @@ -121,10 +122,13 @@ export class WorkspaceSQLiteDB { }; } -export async function openWorkspaceDatabase(workspaceId: string) { - const meta = await getWorkspaceMeta(workspaceId); - const db = new WorkspaceSQLiteDB(meta.mainDBPath, workspaceId); +export async function openWorkspaceDatabase( + spaceType: SpaceType, + spaceId: string +) { + const meta = await getWorkspaceMeta(spaceType, spaceId); + const db = new WorkspaceSQLiteDB(meta.mainDBPath, spaceId); await db.init(); - logger.info(`openWorkspaceDatabase [${workspaceId}]`); + logger.info(`openWorkspaceDatabase [${spaceId}]`); return db; } diff --git a/packages/frontend/electron/src/helper/dialog/dialog.ts b/packages/frontend/electron/src/helper/dialog/dialog.ts index 26c065721f..f3969e26ba 100644 --- a/packages/frontend/electron/src/helper/dialog/dialog.ts +++ b/packages/frontend/electron/src/helper/dialog/dialog.ts @@ -6,11 +6,7 @@ import { ensureSQLiteDB } from '../db/ensure-db'; import { logger } from '../logger'; import { mainRPC } from '../main-rpc'; import { storeWorkspaceMeta } from '../workspace'; -import { - getWorkspaceDBPath, - getWorkspaceMeta, - getWorkspacesBasePath, -} from '../workspace/meta'; +import { getWorkspaceDBPath, getWorkspacesBasePath } from '../workspace/meta'; export type ErrorMessage = | 'DB_FILE_ALREADY_LOADED' @@ -45,17 +41,6 @@ export interface FakeDialogResult { filePaths?: string[]; } -// NOTE: -// we are using native dialogs because HTML dialogs do not give full file paths - -export async function revealDBFile(workspaceId: string) { - const meta = await getWorkspaceMeta(workspaceId); - if (!meta) { - return; - } - await mainRPC.showItemInFolder(meta.mainDBPath); -} - // result will be used in the next call to showOpenDialog // if it is being read once, it will be reset to undefined let fakeDialogResult: FakeDialogResult | undefined = undefined; @@ -91,7 +76,7 @@ export async function saveDBFileAs( workspaceId: string ): Promise { try { - const db = await ensureSQLiteDB(workspaceId); + const db = await ensureSQLiteDB('workspace', workspaceId); const fakedResult = getFakedResult(); const ret = @@ -215,7 +200,7 @@ export async function loadDBFile(): Promise { // copy the db file to a new workspace id const workspaceId = nanoid(10); - const internalFilePath = await getWorkspaceDBPath(workspaceId); + const internalFilePath = await getWorkspaceDBPath('workspace', workspaceId); await fs.ensureDir(await getWorkspacesBasePath()); await fs.copy(originalPath, internalFilePath); diff --git a/packages/frontend/electron/src/helper/dialog/index.ts b/packages/frontend/electron/src/helper/dialog/index.ts index f9bf5c21a7..9c773544b6 100644 --- a/packages/frontend/electron/src/helper/dialog/index.ts +++ b/packages/frontend/electron/src/helper/dialog/index.ts @@ -1,15 +1,11 @@ import { loadDBFile, - revealDBFile, saveDBFileAs, selectDBFileLocation, setFakeDialogResult, } from './dialog'; export const dialogHandlers = { - revealDBFile: async (workspaceId: string) => { - return revealDBFile(workspaceId); - }, loadDBFile: async () => { return loadDBFile(); }, diff --git a/packages/frontend/electron/src/helper/workspace/handlers.ts b/packages/frontend/electron/src/helper/workspace/handlers.ts index 4d6e22f231..641ee9d3d3 100644 --- a/packages/frontend/electron/src/helper/workspace/handlers.ts +++ b/packages/frontend/electron/src/helper/workspace/handlers.ts @@ -8,44 +8,14 @@ import type { WorkspaceMeta } from '../type'; import { getDeletedWorkspacesBasePath, getWorkspaceBasePath, - getWorkspaceDBPath, getWorkspaceMeta, - getWorkspaceMetaPath, - getWorkspacesBasePath, } from './meta'; -import { workspaceSubjects } from './subjects'; - -export async function listWorkspaces(): Promise< - [workspaceId: string, meta: WorkspaceMeta][] -> { - const basePath = await getWorkspacesBasePath(); - try { - await fs.ensureDir(basePath); - const dirs = ( - await fs.readdir(basePath, { - withFileTypes: true, - }) - ).filter(d => d.isDirectory()); - const metaList = ( - await Promise.all( - dirs.map(async dir => { - // ? shall we put all meta in a single file instead of one file per workspace? - return await getWorkspaceMeta(dir.name); - }) - ) - ).filter((w): w is WorkspaceMeta => !!w); - return metaList.map(meta => [meta.id, meta]); - } catch (error) { - logger.error('listWorkspaces', error); - return []; - } -} export async function deleteWorkspace(id: string) { - const basePath = await getWorkspaceBasePath(id); + const basePath = await getWorkspaceBasePath('workspace', id); const movedPath = path.join(await getDeletedWorkspacesBasePath(), `${id}`); try { - const db = await ensureSQLiteDB(id); + const db = await ensureSQLiteDB('workspace', id); await db.destroy(); return await fs.move(basePath, movedPath, { overwrite: true, @@ -55,52 +25,20 @@ export async function deleteWorkspace(id: string) { } } -export async function cloneWorkspace(id: string, newId: string) { - const dbPath = await getWorkspaceDBPath(id); - const newBasePath = await getWorkspaceBasePath(newId); - const newDbPath = await getWorkspaceDBPath(newId); - const metaPath = await getWorkspaceMetaPath(newId); - // check if new workspace dir exists - if ( - await fs - .access(newBasePath) - .then(() => true) - .catch(() => false) - ) { - throw new Error(`workspace ${newId} already exists`); - } - - try { - await fs.ensureDir(newBasePath); - const meta = { - id: newId, - mainDBPath: newDbPath, - }; - await fs.writeJSON(metaPath, meta); - await fs.copy(dbPath, newDbPath); - } catch (error) { - logger.error('cloneWorkspace', error); - } -} - export async function storeWorkspaceMeta( workspaceId: string, meta: Partial ) { try { - const basePath = await getWorkspaceBasePath(workspaceId); + const basePath = await getWorkspaceBasePath('workspace', workspaceId); await fs.ensureDir(basePath); const metaPath = path.join(basePath, 'meta.json'); - const currentMeta = await getWorkspaceMeta(workspaceId); + const currentMeta = await getWorkspaceMeta('workspace', workspaceId); const newMeta = { ...currentMeta, ...meta, }; await fs.writeJSON(metaPath, newMeta); - workspaceSubjects.meta$.next({ - workspaceId, - meta: newMeta, - }); } catch (err) { logger.error('storeWorkspaceMeta failed', err); } diff --git a/packages/frontend/electron/src/helper/workspace/index.ts b/packages/frontend/electron/src/helper/workspace/index.ts index 22c73e2ddb..35db0c4fff 100644 --- a/packages/frontend/electron/src/helper/workspace/index.ts +++ b/packages/frontend/electron/src/helper/workspace/index.ts @@ -1,27 +1,11 @@ -import type { MainEventRegister, WorkspaceMeta } from '../type'; -import { cloneWorkspace, deleteWorkspace, listWorkspaces } from './handlers'; -import { getWorkspaceMeta } from './meta'; -import { workspaceSubjects } from './subjects'; +import type { MainEventRegister } from '../type'; +import { deleteWorkspace } from './handlers'; export * from './handlers'; export * from './subjects'; -export const workspaceEvents = { - onMetaChange: ( - fn: (meta: { workspaceId: string; meta: WorkspaceMeta }) => void - ) => { - const sub = workspaceSubjects.meta$.subscribe(fn); - return () => { - sub.unsubscribe(); - }; - }, -} satisfies Record; +export const workspaceEvents = {} as Record; export const workspaceHandlers = { - list: async () => listWorkspaces(), delete: async (id: string) => deleteWorkspace(id), - getMeta: async (id: string) => { - return getWorkspaceMeta(id); - }, - clone: async (id: string, newId: string) => cloneWorkspace(id, newId), }; diff --git a/packages/frontend/electron/src/helper/workspace/meta.ts b/packages/frontend/electron/src/helper/workspace/meta.ts index 72525974f3..79379bd19e 100644 --- a/packages/frontend/electron/src/helper/workspace/meta.ts +++ b/packages/frontend/electron/src/helper/workspace/meta.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import fs from 'fs-extra'; +import type { SpaceType } from '../db/types'; import { logger } from '../logger'; import { mainRPC } from '../main-rpc'; import type { WorkspaceMeta } from '../type'; @@ -20,20 +21,39 @@ export async function getWorkspacesBasePath() { return path.join(await getAppDataPath(), 'workspaces'); } -export async function getWorkspaceBasePath(workspaceId: string) { - return path.join(await getAppDataPath(), 'workspaces', workspaceId); +export async function getWorkspaceBasePath( + spaceType: SpaceType, + workspaceId: string +) { + return path.join( + await getAppDataPath(), + spaceType === 'userspace' ? 'userspaces' : 'workspaces', + workspaceId + ); } export async function getDeletedWorkspacesBasePath() { return path.join(await getAppDataPath(), 'deleted-workspaces'); } -export async function getWorkspaceDBPath(workspaceId: string) { - return path.join(await getWorkspaceBasePath(workspaceId), 'storage.db'); +export async function getWorkspaceDBPath( + spaceType: SpaceType, + workspaceId: string +) { + return path.join( + await getWorkspaceBasePath(spaceType, workspaceId), + 'storage.db' + ); } -export async function getWorkspaceMetaPath(workspaceId: string) { - return path.join(await getWorkspaceBasePath(workspaceId), 'meta.json'); +export async function getWorkspaceMetaPath( + spaceType: SpaceType, + workspaceId: string +) { + return path.join( + await getWorkspaceBasePath(spaceType, workspaceId), + 'meta.json' + ); } /** @@ -41,11 +61,12 @@ export async function getWorkspaceMetaPath(workspaceId: string) { * This function will also migrate the workspace if needed */ export async function getWorkspaceMeta( + spaceType: SpaceType, workspaceId: string ): Promise { try { - const basePath = await getWorkspaceBasePath(workspaceId); - const metaPath = await getWorkspaceMetaPath(workspaceId); + const basePath = await getWorkspaceBasePath(spaceType, workspaceId); + const metaPath = await getWorkspaceMetaPath(spaceType, workspaceId); if ( !(await fs .access(metaPath) @@ -53,11 +74,12 @@ export async function getWorkspaceMeta( .catch(() => false)) ) { await fs.ensureDir(basePath); - const dbPath = await getWorkspaceDBPath(workspaceId); + const dbPath = await getWorkspaceDBPath(spaceType, workspaceId); // create one if not exists const meta = { id: workspaceId, mainDBPath: dbPath, + type: spaceType, }; await fs.writeJSON(metaPath, meta); return meta; diff --git a/packages/frontend/electron/test/db/ensure-db.spec.ts b/packages/frontend/electron/test/db/ensure-db.spec.ts index 9ec70fd816..191ecd9646 100644 --- a/packages/frontend/electron/test/db/ensure-db.spec.ts +++ b/packages/frontend/electron/test/db/ensure-db.spec.ts @@ -57,16 +57,16 @@ test('can get a valid WorkspaceSQLiteDB', async () => { '@affine/electron/helper/db/ensure-db' ); const workspaceId = v4(); - const db0 = await ensureSQLiteDB(workspaceId); + const db0 = await ensureSQLiteDB('workspace', workspaceId); expect(db0).toBeDefined(); expect(db0.workspaceId).toBe(workspaceId); - const db1 = await ensureSQLiteDB(v4()); + const db1 = await ensureSQLiteDB('workspace', v4()); expect(db1).not.toBe(db0); expect(db1.workspaceId).not.toBe(db0.workspaceId); // ensure that the db is cached - expect(await ensureSQLiteDB(workspaceId)).toBe(db0); + expect(await ensureSQLiteDB('workspace', workspaceId)).toBe(db0); }); test('db should be destroyed when app quits', async () => { @@ -74,8 +74,8 @@ test('db should be destroyed when app quits', async () => { '@affine/electron/helper/db/ensure-db' ); const workspaceId = v4(); - const db0 = await ensureSQLiteDB(workspaceId); - const db1 = await ensureSQLiteDB(v4()); + const db0 = await ensureSQLiteDB('workspace', workspaceId); + const db1 = await ensureSQLiteDB('workspace', v4()); expect(db0.adapter).not.toBeNull(); expect(db1.adapter).not.toBeNull(); @@ -94,8 +94,8 @@ test('db should be removed in db$Map after destroyed', async () => { '@affine/electron/helper/db/ensure-db' ); const workspaceId = v4(); - const db = await ensureSQLiteDB(workspaceId); + const db = await ensureSQLiteDB('workspace', workspaceId); await db.destroy(); await setTimeout(100); - expect(db$Map.has(workspaceId)).toBe(false); + expect(db$Map.has(`workspace:${workspaceId}`)).toBe(false); }); diff --git a/packages/frontend/electron/test/db/workspace-db-adapter.spec.ts b/packages/frontend/electron/test/db/workspace-db-adapter.spec.ts index c5b5c8f642..f06f0fa3ad 100644 --- a/packages/frontend/electron/test/db/workspace-db-adapter.spec.ts +++ b/packages/frontend/electron/test/db/workspace-db-adapter.spec.ts @@ -29,7 +29,7 @@ test('can create new db file if not exists', async () => { '@affine/electron/helper/db/workspace-db-adapter' ); const workspaceId = v4(); - const db = await openWorkspaceDatabase(workspaceId); + const db = await openWorkspaceDatabase('workspace', workspaceId); const dbPath = path.join( appDataPath, `workspaces/${workspaceId}`, @@ -44,7 +44,7 @@ test('on destroy, check if resources have been released', async () => { '@affine/electron/helper/db/workspace-db-adapter' ); const workspaceId = v4(); - const db = await openWorkspaceDatabase(workspaceId); + const db = await openWorkspaceDatabase('workspace', workspaceId); const updateSub = { complete: vi.fn(), next: vi.fn(), diff --git a/packages/frontend/electron/test/workspace/handlers.spec.ts b/packages/frontend/electron/test/workspace/handlers.spec.ts index fde95ca9ff..eed77a8f3d 100644 --- a/packages/frontend/electron/test/workspace/handlers.spec.ts +++ b/packages/frontend/electron/test/workspace/handlers.spec.ts @@ -28,40 +28,6 @@ afterAll(() => { vi.doUnmock('@affine/electron/helper/main-rpc'); }); -describe('list workspaces', () => { - test('listWorkspaces (valid)', async () => { - const { listWorkspaces } = await import( - '@affine/electron/helper/workspace/handlers' - ); - const workspaceId = v4(); - const workspacePath = path.join(appDataPath, 'workspaces', workspaceId); - const meta = { - id: workspaceId, - }; - await fs.ensureDir(workspacePath); - await fs.writeJSON(path.join(workspacePath, 'meta.json'), meta); - const workspaces = await listWorkspaces(); - expect(workspaces).toEqual([[workspaceId, meta]]); - }); - - test('listWorkspaces (without meta json file)', async () => { - const { listWorkspaces } = await import( - '@affine/electron/helper/workspace/handlers' - ); - const workspaceId = v4(); - const workspacePath = path.join(appDataPath, 'workspaces', workspaceId); - await fs.ensureDir(workspacePath); - const workspaces = await listWorkspaces(); - expect(workspaces).toEqual([ - [ - workspaceId, - // meta file will be created automatically - { id: workspaceId, mainDBPath: path.join(workspacePath, 'storage.db') }, - ], - ]); - }); -}); - describe('delete workspace', () => { test('deleteWorkspace', async () => { const { deleteWorkspace } = await import( @@ -93,7 +59,7 @@ describe('getWorkspaceMeta', () => { }; await fs.ensureDir(workspacePath); await fs.writeJSON(path.join(workspacePath, 'meta.json'), meta); - expect(await getWorkspaceMeta(workspaceId)).toEqual(meta); + expect(await getWorkspaceMeta('workspace', workspaceId)).toEqual(meta); }); test('can create meta if not exists', async () => { @@ -103,9 +69,10 @@ describe('getWorkspaceMeta', () => { const workspaceId = v4(); const workspacePath = path.join(appDataPath, 'workspaces', workspaceId); await fs.ensureDir(workspacePath); - expect(await getWorkspaceMeta(workspaceId)).toEqual({ + expect(await getWorkspaceMeta('workspace', workspaceId)).toEqual({ id: workspaceId, mainDBPath: path.join(workspacePath, 'storage.db'), + type: 'workspace', }); expect( await fs.pathExists(path.join(workspacePath, 'meta.json')) @@ -124,9 +91,10 @@ describe('getWorkspaceMeta', () => { await fs.ensureSymlink(sourcePath, path.join(workspacePath, 'storage.db')); - expect(await getWorkspaceMeta(workspaceId)).toEqual({ + expect(await getWorkspaceMeta('workspace', workspaceId)).toEqual({ id: workspaceId, mainDBPath: path.join(workspacePath, 'storage.db'), + type: 'workspace', }); expect( @@ -145,6 +113,7 @@ test('storeWorkspaceMeta', async () => { const meta = { id: workspaceId, mainDBPath: path.join(workspacePath, 'storage.db'), + type: 'workspace', }; await storeWorkspaceMeta(workspaceId, meta); expect(await fs.readJSON(path.join(workspacePath, 'meta.json'))).toEqual( diff --git a/packages/frontend/web/src/app.tsx b/packages/frontend/web/src/app.tsx index 9477bc6ae2..0b41b13de1 100644 --- a/packages/frontend/web/src/app.tsx +++ b/packages/frontend/web/src/app.tsx @@ -7,6 +7,7 @@ import { AppFallback } from '@affine/core/components/affine/app-container'; import { configureCommonModules } from '@affine/core/modules'; import { configureLocalStorageStateStorageImpls } from '@affine/core/modules/storage'; import { CustomThemeModifier } from '@affine/core/modules/theme-editor'; +import { configureIndexedDBUserspaceStorageProvider } from '@affine/core/modules/userspace'; import { configureBrowserWorkbenchModule } from '@affine/core/modules/workbench'; import { configureBrowserWorkspaceFlavours, @@ -75,6 +76,7 @@ configureBrowserWorkbenchModule(framework); configureLocalStorageStateStorageImpls(framework); configureBrowserWorkspaceFlavours(framework); configureIndexedDBWorkspaceEngineStorageProvider(framework); +configureIndexedDBUserspaceStorageProvider(framework); const frameworkProvider = framework.provider(); // setup application lifecycle events, and emit application start event