feat(core): new worker workspace engine (#9257)
This commit is contained in:
@@ -1,17 +1,21 @@
|
||||
import { DocEngine, Entity } from '@toeverything/infra';
|
||||
import { IndexedDBDocStorage } from '@affine/nbstore/idb';
|
||||
import { SqliteDocStorage } from '@affine/nbstore/sqlite';
|
||||
import type { WorkerClient } from '@affine/nbstore/worker/client';
|
||||
import { Entity } from '@toeverything/infra';
|
||||
|
||||
import type { WebSocketService } from '../../cloud';
|
||||
import { UserDBDocServer } from '../impls/user-db-doc-server';
|
||||
import type { UserspaceStorageProvider } from '../provider/storage';
|
||||
import type { ServerService } from '../../cloud';
|
||||
import type { NbstoreService } from '../../storage';
|
||||
|
||||
export class UserDBEngine extends Entity<{
|
||||
userId: string;
|
||||
}> {
|
||||
private readonly userId = this.props.userId;
|
||||
readonly docEngine = new DocEngine(
|
||||
this.userspaceStorageProvider.getDocStorage('affine-cloud:' + this.userId),
|
||||
new UserDBDocServer(this.userId, this.websocketService)
|
||||
);
|
||||
readonly client: WorkerClient;
|
||||
|
||||
DocStorageType =
|
||||
BUILD_CONFIG.isElectron || BUILD_CONFIG.isIOS
|
||||
? SqliteDocStorage
|
||||
: IndexedDBDocStorage;
|
||||
|
||||
canGracefulStop() {
|
||||
// TODO(@eyhn): Implement this
|
||||
@@ -19,14 +23,40 @@ export class UserDBEngine extends Entity<{
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly userspaceStorageProvider: UserspaceStorageProvider,
|
||||
private readonly websocketService: WebSocketService
|
||||
private readonly nbstoreService: NbstoreService,
|
||||
serverService: ServerService
|
||||
) {
|
||||
super();
|
||||
this.docEngine.start();
|
||||
}
|
||||
|
||||
override dispose() {
|
||||
this.docEngine.stop();
|
||||
const { store, dispose } = this.nbstoreService.openStore(
|
||||
`userspace:${serverService.server.id},${this.userId}`,
|
||||
{
|
||||
local: {
|
||||
doc: {
|
||||
name: this.DocStorageType.identifier,
|
||||
opts: {
|
||||
id: `${serverService.server.id}:` + this.userId,
|
||||
flavour: serverService.server.id,
|
||||
type: 'userspace',
|
||||
},
|
||||
},
|
||||
},
|
||||
remotes: {
|
||||
cloud: {
|
||||
doc: {
|
||||
name: 'CloudDocStorage',
|
||||
opts: {
|
||||
id: this.userId,
|
||||
serverBaseUrl: serverService.server.baseUrl,
|
||||
type: 'userspace',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
this.client = store;
|
||||
this.client.docFrontend.start();
|
||||
this.disposables.push(() => dispose());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { DocFrontendDocState } from '@affine/nbstore';
|
||||
import type {
|
||||
Table as OrmTable,
|
||||
TableSchemaBuilder,
|
||||
} from '@toeverything/infra';
|
||||
import { Entity } from '@toeverything/infra';
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
|
||||
import type { UserDBEngine } from './user-db-engine';
|
||||
|
||||
@@ -12,15 +13,16 @@ export class UserDBTable<Schema extends TableSchemaBuilder> extends Entity<{
|
||||
engine: UserDBEngine;
|
||||
}> {
|
||||
readonly table = this.props.table;
|
||||
readonly docEngine = this.props.engine.docEngine;
|
||||
readonly docFrontend = this.props.engine.client.docFrontend;
|
||||
|
||||
isSyncing$ = this.docEngine
|
||||
.docState$(this.props.storageDocId)
|
||||
.map(docState => docState.syncing);
|
||||
docSyncState$ = LiveData.from<DocFrontendDocState>(
|
||||
this.docFrontend.docState$(this.props.storageDocId),
|
||||
null as any
|
||||
);
|
||||
|
||||
isLoading$ = this.docEngine
|
||||
.docState$(this.props.storageDocId)
|
||||
.map(docState => docState.loading);
|
||||
isSyncing$ = this.docSyncState$.map(docState => docState.syncing);
|
||||
|
||||
isLoaded$ = this.docSyncState$.map(docState => docState.loaded);
|
||||
|
||||
create: typeof this.table.create = this.table.create.bind(this.table);
|
||||
update: typeof this.table.update = this.table.update.bind(this.table);
|
||||
|
||||
@@ -19,8 +19,8 @@ export class UserDB extends Entity<{
|
||||
const ydoc = new YDoc({
|
||||
guid,
|
||||
});
|
||||
this.engine.docEngine.addDoc(ydoc, false);
|
||||
this.engine.docEngine.setPriority(ydoc.guid, 50);
|
||||
this.engine.client.docFrontend.connectDoc(ydoc);
|
||||
this.engine.client.docFrontend.addPriority(ydoc.guid, 50);
|
||||
return ydoc;
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import type {
|
||||
ByteKV,
|
||||
ByteKVBehavior,
|
||||
DocEvent,
|
||||
DocEventBus,
|
||||
DocStorage,
|
||||
} from '@toeverything/infra';
|
||||
import { AsyncLock } from '@toeverything/infra';
|
||||
|
||||
import type { DesktopApiService } from '../../desktop-api';
|
||||
|
||||
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,
|
||||
private readonly electronApi: DesktopApiService
|
||||
) {}
|
||||
eventBus = new BroadcastChannelDocEventBus(this.userId);
|
||||
readonly doc = new Doc(this.userId, this.electronApi);
|
||||
readonly syncMetadata = new SyncMetadataKV(this.userId, this.electronApi);
|
||||
readonly serverClock = new ServerClockKV(this.userId, this.electronApi);
|
||||
}
|
||||
|
||||
type DocType = DocStorage['doc'];
|
||||
|
||||
class Doc implements DocType {
|
||||
lock = new AsyncLock();
|
||||
apis = this.electronApi.api.handler;
|
||||
|
||||
constructor(
|
||||
private readonly userId: string,
|
||||
private readonly electronApi: DesktopApiService
|
||||
) {}
|
||||
|
||||
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) {
|
||||
const update = await this.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) {
|
||||
await this.apis.db.applyDocUpdate('userspace', this.userId, data, docId);
|
||||
}
|
||||
|
||||
clear(): void | Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
async del(docId: string) {
|
||||
await this.apis.db.deleteDoc('userspace', this.userId, docId);
|
||||
}
|
||||
}
|
||||
|
||||
class SyncMetadataKV implements ByteKV {
|
||||
apis = this.electronApi.api.handler;
|
||||
constructor(
|
||||
private readonly userId: string,
|
||||
private readonly electronApi: DesktopApiService
|
||||
) {}
|
||||
transaction<T>(cb: (behavior: ByteKVBehavior) => Promise<T>): Promise<T> {
|
||||
return cb(this);
|
||||
}
|
||||
|
||||
get(key: string): Uint8Array | null | Promise<Uint8Array | null> {
|
||||
return this.apis.db.getSyncMetadata('userspace', this.userId, key);
|
||||
}
|
||||
|
||||
set(key: string, data: Uint8Array): void | Promise<void> {
|
||||
return this.apis.db.setSyncMetadata('userspace', this.userId, key, data);
|
||||
}
|
||||
|
||||
keys(): string[] | Promise<string[]> {
|
||||
return this.apis.db.getSyncMetadataKeys('userspace', this.userId);
|
||||
}
|
||||
|
||||
del(key: string): void | Promise<void> {
|
||||
return this.apis.db.delSyncMetadata('userspace', this.userId, key);
|
||||
}
|
||||
|
||||
clear(): void | Promise<void> {
|
||||
return this.apis.db.clearSyncMetadata('userspace', this.userId);
|
||||
}
|
||||
}
|
||||
|
||||
class ServerClockKV implements ByteKV {
|
||||
apis = this.electronApi.api.handler;
|
||||
constructor(
|
||||
private readonly userId: string,
|
||||
private readonly electronApi: DesktopApiService
|
||||
) {}
|
||||
transaction<T>(cb: (behavior: ByteKVBehavior) => Promise<T>): Promise<T> {
|
||||
return cb(this);
|
||||
}
|
||||
|
||||
get(key: string): Uint8Array | null | Promise<Uint8Array | null> {
|
||||
return this.apis.db.getServerClock('userspace', this.userId, key);
|
||||
}
|
||||
|
||||
set(key: string, data: Uint8Array): void | Promise<void> {
|
||||
return this.apis.db.setServerClock('userspace', this.userId, key, data);
|
||||
}
|
||||
|
||||
keys(): string[] | Promise<string[]> {
|
||||
return this.apis.db.getServerClockKeys('userspace', this.userId);
|
||||
}
|
||||
|
||||
del(key: string): void | Promise<void> {
|
||||
return this.apis.db.delServerClock('userspace', this.userId, key);
|
||||
}
|
||||
|
||||
clear(): void | Promise<void> {
|
||||
return this.apis.db.clearServerClock('userspace', this.userId);
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
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 type { WebSocketService } from '../../cloud';
|
||||
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;
|
||||
|
||||
socket: Socket;
|
||||
disposeSocket: () => void;
|
||||
|
||||
constructor(
|
||||
private readonly userId: string,
|
||||
webSocketService: WebSocketService
|
||||
) {
|
||||
const { socket, dispose } = webSocketService.connect();
|
||||
this.socket = socket;
|
||||
this.disposeSocket = dispose;
|
||||
}
|
||||
|
||||
private async clientHandShake() {
|
||||
await this.socket.emitWithAck('space:join', {
|
||||
spaceType: 'userspace',
|
||||
spaceId: this.userId,
|
||||
clientVersion: BUILD_CONFIG.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 {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.socket.on('connect', () => {
|
||||
resolve();
|
||||
});
|
||||
signal.addEventListener('abort', () => {
|
||||
reject('aborted');
|
||||
});
|
||||
});
|
||||
throwIfAborted(signal);
|
||||
await this.clientHandShake();
|
||||
}
|
||||
}
|
||||
disconnectServer(): void {
|
||||
this.socket.emit('space:leave', {
|
||||
spaceType: 'userspace',
|
||||
spaceId: this.userId,
|
||||
});
|
||||
this.socket.off('server-version-rejected', this.handleVersionRejected);
|
||||
this.socket.off('disconnect', this.handleDisconnect);
|
||||
}
|
||||
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');
|
||||
};
|
||||
|
||||
dispose(): void {
|
||||
this.disconnectServer();
|
||||
this.disposeSocket();
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,13 @@ export { UserspaceService as UserDBService } from './services/userspace';
|
||||
|
||||
import type { Framework } from '@toeverything/infra';
|
||||
|
||||
import { AuthService, WebSocketService } from '../cloud';
|
||||
import { AuthService, ServerService } from '../cloud';
|
||||
import { ServerScope } from '../cloud/scopes/server';
|
||||
import { DesktopApiService } from '../desktop-api/service/desktop-api';
|
||||
import { NbstoreService } from '../storage';
|
||||
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) {
|
||||
@@ -21,23 +18,5 @@ export function configureUserspaceModule(framework: Framework) {
|
||||
.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, p => ({
|
||||
getDocStorage(userId: string) {
|
||||
return new SqliteUserspaceDocStorage(userId, p.get(DesktopApiService));
|
||||
},
|
||||
}));
|
||||
.entity(UserDBEngine, [NbstoreService, ServerService]);
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { createIdentifier, type DocStorage } from '@toeverything/infra';
|
||||
|
||||
export interface UserspaceStorageProvider {
|
||||
getDocStorage(userId: string): DocStorage;
|
||||
}
|
||||
|
||||
export const UserspaceStorageProvider =
|
||||
createIdentifier<UserspaceStorageProvider>('UserspaceStorageProvider');
|
||||
Reference in New Issue
Block a user