mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-18 14:56:59 +08:00
feat(core): user data db (#7930)
This commit is contained in:
@@ -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<Record<string, string>> {
|
||||
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<string, string>
|
||||
);
|
||||
})
|
||||
);
|
||||
} 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<Record<string, string>>(storageKey) ?? {};
|
||||
const after = {
|
||||
...all,
|
||||
[key]: value,
|
||||
};
|
||||
this.globalState.set(storageKey, after);
|
||||
}
|
||||
get(key: string): string | undefined {
|
||||
return this.globalState.get<Record<string, string>>(storageKey)?.[key];
|
||||
}
|
||||
watchAll(): Observable<Record<string, string>> {
|
||||
return this.globalState
|
||||
.watch<Record<string, string>>(storageKey)
|
||||
.pipe(map(all => all ?? {}));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<Schema extends TableSchemaBuilder> extends Entity<{
|
||||
table: OrmTable<Schema>;
|
||||
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);
|
||||
}
|
||||
@@ -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<USER_DB_SCHEMA[K]>;
|
||||
};
|
||||
@@ -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<DocEvent>) => {
|
||||
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<IDBPDatabase<DocDBSchema>> | null = null;
|
||||
dbVersion = 1;
|
||||
|
||||
constructor(private readonly userId: string) {}
|
||||
|
||||
upgradeDB(db: IDBPDatabase<DocDBSchema>) {
|
||||
db.createObjectStore('userspace', { keyPath: 'id' });
|
||||
}
|
||||
|
||||
getDb() {
|
||||
if (this.dbPromise === null) {
|
||||
this.dbPromise = openDB<DocDBSchema>(this.dbName, this.dbVersion, {
|
||||
upgrade: db => this.upgradeDB(db),
|
||||
});
|
||||
}
|
||||
return this.dbPromise;
|
||||
}
|
||||
|
||||
async get(docId: string): Promise<Uint8Array | null> {
|
||||
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<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
del(_key: string): void | Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
async transaction<T>(
|
||||
cb: (transaction: ByteKVBehavior) => Promise<T>
|
||||
): Promise<T> {
|
||||
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<IDBPDatabase<KvDBSchema>> | null = null;
|
||||
dbVersion = 1;
|
||||
|
||||
upgradeDB(db: IDBPDatabase<KvDBSchema>) {
|
||||
db.createObjectStore('kv', { keyPath: 'key' });
|
||||
}
|
||||
|
||||
getDb() {
|
||||
if (this.dbPromise === null) {
|
||||
this.dbPromise = openDB<KvDBSchema>(this.dbName, this.dbVersion, {
|
||||
upgrade: db => this.upgradeDB(db),
|
||||
});
|
||||
}
|
||||
return this.dbPromise;
|
||||
}
|
||||
|
||||
async transaction<T>(
|
||||
cb: (transaction: ByteKVBehavior) => Promise<T>
|
||||
): Promise<T> {
|
||||
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<Uint8Array | null> {
|
||||
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<void> {
|
||||
const db = await this.getDb();
|
||||
const store = db.transaction('kv', 'readwrite').objectStore('kv');
|
||||
return new KVBehavior(store).set(key, value);
|
||||
}
|
||||
async keys(): Promise<string[]> {
|
||||
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<KvDBSchema, ['kv'], 'kv', any>
|
||||
) {}
|
||||
async get(key: string): Promise<Uint8Array | null> {
|
||||
const value = await this.store.get(key);
|
||||
return value?.val ?? null;
|
||||
}
|
||||
async set(key: string, value: Uint8Array): Promise<void> {
|
||||
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<string[]> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<DocEvent>) => {
|
||||
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<T>(
|
||||
cb: (transaction: ByteKVBehavior) => Promise<T>
|
||||
): Promise<T> {
|
||||
using _lock = await this.lock.acquire();
|
||||
return await cb(this);
|
||||
}
|
||||
|
||||
keys(): string[] | Promise<string[]> {
|
||||
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<void> {
|
||||
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<T>(cb: (behavior: ByteKVBehavior) => Promise<T>): Promise<T> {
|
||||
return cb(this);
|
||||
}
|
||||
|
||||
get(key: string): Uint8Array | null | Promise<Uint8Array | null> {
|
||||
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<void> {
|
||||
if (!apis?.db) {
|
||||
throw new Error('sqlite datasource is not available');
|
||||
}
|
||||
return apis.db.setSyncMetadata('userspace', this.userId, key, data);
|
||||
}
|
||||
|
||||
keys(): string[] | Promise<string[]> {
|
||||
if (!apis?.db) {
|
||||
throw new Error('sqlite datasource is not available');
|
||||
}
|
||||
return apis.db.getSyncMetadataKeys('userspace', this.userId);
|
||||
}
|
||||
|
||||
del(key: string): void | Promise<void> {
|
||||
if (!apis?.db) {
|
||||
throw new Error('sqlite datasource is not available');
|
||||
}
|
||||
return apis.db.delSyncMetadata('userspace', this.userId, key);
|
||||
}
|
||||
|
||||
clear(): void | Promise<void> {
|
||||
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<T>(cb: (behavior: ByteKVBehavior) => Promise<T>): Promise<T> {
|
||||
return cb(this);
|
||||
}
|
||||
|
||||
get(key: string): Uint8Array | null | Promise<Uint8Array | null> {
|
||||
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<void> {
|
||||
if (!apis?.db) {
|
||||
throw new Error('sqlite datasource is not available');
|
||||
}
|
||||
return apis.db.setServerClock('userspace', this.userId, key, data);
|
||||
}
|
||||
|
||||
keys(): string[] | Promise<string[]> {
|
||||
if (!apis?.db) {
|
||||
throw new Error('sqlite datasource is not available');
|
||||
}
|
||||
return apis.db.getServerClockKeys('userspace', this.userId);
|
||||
}
|
||||
|
||||
del(key: string): void | Promise<void> {
|
||||
if (!apis?.db) {
|
||||
throw new Error('sqlite datasource is not available');
|
||||
}
|
||||
return apis.db.delServerClock('userspace', this.userId, key);
|
||||
}
|
||||
|
||||
clear(): void | Promise<void> {
|
||||
if (!apis?.db) {
|
||||
throw new Error('sqlite datasource is not available');
|
||||
}
|
||||
return apis.db.clearServerClock('userspace', this.userId);
|
||||
}
|
||||
}
|
||||
@@ -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<T> = { 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<Map<string, number>> {
|
||||
const response: WebsocketResponse<Record<string, number>> =
|
||||
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<void> {
|
||||
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<void>((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');
|
||||
};
|
||||
}
|
||||
40
packages/frontend/core/src/modules/userspace/index.ts
Normal file
40
packages/frontend/core/src/modules/userspace/index.ts
Normal file
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createIdentifier, type DocStorage } from '@toeverything/infra';
|
||||
|
||||
export interface UserspaceStorageProvider {
|
||||
getDocStorage(userId: string): DocStorage;
|
||||
}
|
||||
|
||||
export const UserspaceStorageProvider =
|
||||
createIdentifier<UserspaceStorageProvider>('UserspaceStorageProvider');
|
||||
@@ -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;
|
||||
@@ -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<string, UserDBWithTables>({
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
@@ -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<void> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (!apis?.db) {
|
||||
throw new Error('sqlite datasource is not available');
|
||||
}
|
||||
return apis.db.clearServerClock(this.workspaceId);
|
||||
return apis.db.clearServerClock('workspace', this.workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, Promise<WorkspaceSQLiteDB>>();
|
||||
export const db$Map = new Map<
|
||||
`${SpaceType}:${string}`,
|
||||
Promise<WorkspaceSQLiteDB>
|
||||
>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
1
packages/frontend/electron/src/helper/db/types.ts
Normal file
1
packages/frontend/electron/src/helper/db/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type SpaceType = 'userspace' | 'workspace';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<SaveDBFileResult> {
|
||||
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<LoadDBFileResult> {
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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<WorkspaceMeta>
|
||||
) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<string, MainEventRegister>;
|
||||
export const workspaceEvents = {} as Record<string, MainEventRegister>;
|
||||
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -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<WorkspaceMeta> {
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user