mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 18:26:05 +08:00
feat(nbstore): init (#7639)
TODO - [x] basic - [x] storages - [x] producer/consumer - [x] operation pattern - [x] events - [x] worker - [x] readme - [x] peer dependencies
This commit is contained in:
@@ -54,7 +54,7 @@ const allPackages = [
|
|||||||
'packages/common/debug',
|
'packages/common/debug',
|
||||||
'packages/common/env',
|
'packages/common/env',
|
||||||
'packages/common/infra',
|
'packages/common/infra',
|
||||||
'packages/common/theme',
|
'packages/common/nbstore',
|
||||||
'tools/cli',
|
'tools/cli',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
69
packages/common/nbstore/README.md
Normal file
69
packages/common/nbstore/README.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Space Storage
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Independent Storage usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { ConnectionStatus } from '@affine/nbstore';
|
||||||
|
import { IndexedDBDocStorage } from '@affine/nbstore/idb';
|
||||||
|
|
||||||
|
const storage = new IndexedDBDocStorage({
|
||||||
|
peer: 'local'
|
||||||
|
spaceId: 'my-new-workspace',
|
||||||
|
});
|
||||||
|
|
||||||
|
await storage.connect();
|
||||||
|
storage.connection.onStatusChange((status: ConnectionStatus, error?: Error) => {
|
||||||
|
ui.show(status, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// { docId: string, bin: Uint8Array, timestamp: Date, editor?: string } | null
|
||||||
|
const doc = await storage.getDoc('my-first-doc');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use All storages together
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { SpaceStorage } from '@affine/nbstore';
|
||||||
|
import type { ConnectionStatus } from '@affine/nbstore';
|
||||||
|
import { IndexedDBDocStorage } from '@affine/nbstore/idb';
|
||||||
|
import { SqliteBlobStorage } from '@affine/nbstore/sqlite';
|
||||||
|
|
||||||
|
const storage = new SpaceStorage([new IndexedDBDocStorage({}), new SqliteBlobStorage({})]);
|
||||||
|
|
||||||
|
await storage.connect();
|
||||||
|
storage.on('connection', ({ storage, status, error }) => {
|
||||||
|
ui.show(storage, status, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
await storage.get('doc').pushDocUpdate({ docId: 'my-first-doc', bin: new Uint8Array(), editor: 'me' });
|
||||||
|
await storage.tryGet('blob')?.get('img');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Put Storage behind Worker
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { SpaceStorageWorkerClient } from '@affine/nbstore/op';
|
||||||
|
import type { ConnectionStatus } from '@affine/nbstore';
|
||||||
|
import { IndexedDBDocStorage } from '@affine/nbstore/idb';
|
||||||
|
|
||||||
|
const client = new SpaceStorageWorkerClient();
|
||||||
|
client.addStorage(IndexedDBDocStorage, {
|
||||||
|
// options can only be structure-cloneable type
|
||||||
|
peer: 'local',
|
||||||
|
spaceType: 'workspace',
|
||||||
|
spaceId: 'my-new-workspace',
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
client.ob$('connection', ({ storage, status, error }) => {
|
||||||
|
ui.show(storage, status, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.call('pushDocUpdate', { docId: 'my-first-doc', bin: new Uint8Array(), editor: 'me' });
|
||||||
|
|
||||||
|
// call unregistered op will leads to Error
|
||||||
|
// Error { message: 'Handler for operation [listHistory] is not registered.' }
|
||||||
|
await client.call('listHistories', { docId: 'my-first-doc' });
|
||||||
|
```
|
||||||
17
packages/common/nbstore/package.json
Normal file
17
packages/common/nbstore/package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@affine/nbstore",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.18.0",
|
||||||
|
"private": true,
|
||||||
|
"sideEffects": false,
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@toeverything/infra": "workspace:*",
|
||||||
|
"eventemitter2": "^6.4.9",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"yjs": "patch:yjs@npm%3A13.6.18#~/.yarn/patches/yjs-npm-13.6.18-ad0d5f7c43.patch"
|
||||||
|
}
|
||||||
|
}
|
||||||
132
packages/common/nbstore/src/connection/connection.ts
Normal file
132
packages/common/nbstore/src/connection/connection.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import EventEmitter2 from 'eventemitter2';
|
||||||
|
|
||||||
|
export type ConnectionStatus =
|
||||||
|
| 'idle'
|
||||||
|
| 'connecting'
|
||||||
|
| 'connected'
|
||||||
|
| 'error'
|
||||||
|
| 'closed';
|
||||||
|
|
||||||
|
export abstract class Connection<T = any> {
|
||||||
|
private readonly event = new EventEmitter2();
|
||||||
|
private _inner: T | null = null;
|
||||||
|
private _status: ConnectionStatus = 'idle';
|
||||||
|
protected error?: Error;
|
||||||
|
private refCount = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.autoReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
get shareId(): string | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
get maybeConnection() {
|
||||||
|
return this._inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
get inner(): T {
|
||||||
|
if (!this._inner) {
|
||||||
|
throw new Error(
|
||||||
|
`Connection ${this.constructor.name} has not been established.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected set inner(inner: T | null) {
|
||||||
|
this._inner = inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
get status() {
|
||||||
|
return this._status;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setStatus(status: ConnectionStatus, error?: Error) {
|
||||||
|
const shouldEmit = status !== this._status && error !== this.error;
|
||||||
|
this._status = status;
|
||||||
|
this.error = error;
|
||||||
|
if (shouldEmit) {
|
||||||
|
this.emitStatusChanged(status, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract doConnect(): Promise<T>;
|
||||||
|
abstract doDisconnect(conn: T): Promise<void>;
|
||||||
|
|
||||||
|
ref() {
|
||||||
|
this.refCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
deref() {
|
||||||
|
this.refCount = Math.max(0, this.refCount - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
if (this.status === 'idle' || this.status === 'error') {
|
||||||
|
this.setStatus('connecting');
|
||||||
|
try {
|
||||||
|
this._inner = await this.doConnect();
|
||||||
|
this.setStatus('connected');
|
||||||
|
} catch (error) {
|
||||||
|
this.setStatus('error', error as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect() {
|
||||||
|
this.deref();
|
||||||
|
if (this.refCount > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.status === 'connected') {
|
||||||
|
try {
|
||||||
|
if (this._inner) {
|
||||||
|
await this.doDisconnect(this._inner);
|
||||||
|
this._inner = null;
|
||||||
|
}
|
||||||
|
this.setStatus('closed');
|
||||||
|
} catch (error) {
|
||||||
|
this.setStatus('error', error as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private autoReconnect() {
|
||||||
|
// TODO:
|
||||||
|
// - maximum retry count
|
||||||
|
// - dynamic sleep time (attempt < 3 ? 1s : 1min)?
|
||||||
|
this.onStatusChanged(() => {
|
||||||
|
this.connect().catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatusChanged(
|
||||||
|
cb: (status: ConnectionStatus, error?: Error) => void
|
||||||
|
): () => void {
|
||||||
|
this.event.on('statusChanged', cb);
|
||||||
|
return () => {
|
||||||
|
this.event.off('statusChanged', cb);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly emitStatusChanged = (
|
||||||
|
status: ConnectionStatus,
|
||||||
|
error?: Error
|
||||||
|
) => {
|
||||||
|
this.event.emit('statusChanged', status, error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DummyConnection extends Connection<undefined> {
|
||||||
|
doConnect() {
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
doDisconnect() {
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
packages/common/nbstore/src/connection/index.ts
Normal file
2
packages/common/nbstore/src/connection/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './connection';
|
||||||
|
export * from './shared-connection';
|
||||||
22
packages/common/nbstore/src/connection/shared-connection.ts
Normal file
22
packages/common/nbstore/src/connection/shared-connection.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Connection } from './connection';
|
||||||
|
|
||||||
|
const CONNECTIONS: Map<string, Connection<any>> = new Map();
|
||||||
|
export function share<T extends Connection<any>>(conn: T): T {
|
||||||
|
if (!conn.shareId) {
|
||||||
|
throw new Error(
|
||||||
|
`Connection ${conn.constructor.name} is not shareable.\nIf you want to make it shareable, please override [shareId].`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = CONNECTIONS.get(conn.shareId);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.ref();
|
||||||
|
return existing as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
CONNECTIONS.set(conn.shareId, conn);
|
||||||
|
conn.ref();
|
||||||
|
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
2
packages/common/nbstore/src/index.ts
Normal file
2
packages/common/nbstore/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './connection';
|
||||||
|
export * from './storage';
|
||||||
29
packages/common/nbstore/src/storage/blob.ts
Normal file
29
packages/common/nbstore/src/storage/blob.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Storage, type StorageOptions } from './storage';
|
||||||
|
|
||||||
|
export interface BlobStorageOptions extends StorageOptions {}
|
||||||
|
|
||||||
|
export interface BlobRecord {
|
||||||
|
key: string;
|
||||||
|
data: Uint8Array;
|
||||||
|
mime: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListedBlobRecord {
|
||||||
|
key: string;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class BlobStorage<
|
||||||
|
Options extends BlobStorageOptions = BlobStorageOptions,
|
||||||
|
> extends Storage<Options> {
|
||||||
|
override readonly storageType = 'blob';
|
||||||
|
|
||||||
|
abstract get(key: string): Promise<BlobRecord | null>;
|
||||||
|
abstract set(blob: BlobRecord): Promise<void>;
|
||||||
|
abstract delete(key: string, permanently: boolean): Promise<void>;
|
||||||
|
abstract release(): Promise<void>;
|
||||||
|
abstract list(): Promise<ListedBlobRecord[]>;
|
||||||
|
}
|
||||||
258
packages/common/nbstore/src/storage/doc.ts
Normal file
258
packages/common/nbstore/src/storage/doc.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import EventEmitter2 from 'eventemitter2';
|
||||||
|
import { diffUpdate, encodeStateVectorFromUpdate, mergeUpdates } from 'yjs';
|
||||||
|
|
||||||
|
import type { Lock } from './lock';
|
||||||
|
import { SingletonLocker } from './lock';
|
||||||
|
import { Storage, type StorageOptions } from './storage';
|
||||||
|
|
||||||
|
export interface DocClock {
|
||||||
|
docId: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocClocks = Record<string, Date>;
|
||||||
|
export interface DocRecord extends DocClock {
|
||||||
|
bin: Uint8Array;
|
||||||
|
editor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocDiff extends DocClock {
|
||||||
|
missing: Uint8Array;
|
||||||
|
state: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocUpdate {
|
||||||
|
docId: string;
|
||||||
|
bin: Uint8Array;
|
||||||
|
editor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Editor {
|
||||||
|
name: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocStorageOptions extends StorageOptions {
|
||||||
|
mergeUpdates?: (updates: Uint8Array[]) => Promise<Uint8Array> | Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class DocStorage<
|
||||||
|
Opts extends DocStorageOptions = DocStorageOptions,
|
||||||
|
> extends Storage<Opts> {
|
||||||
|
private readonly event = new EventEmitter2();
|
||||||
|
override readonly storageType = 'doc';
|
||||||
|
private readonly locker = new SingletonLocker();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tell a binary is empty yjs binary or not.
|
||||||
|
*
|
||||||
|
* NOTE:
|
||||||
|
* `[0, 0]` is empty yjs update binary
|
||||||
|
* `[0]` is empty yjs state vector binary
|
||||||
|
*/
|
||||||
|
isEmptyBin(bin: Uint8Array): boolean {
|
||||||
|
return (
|
||||||
|
bin.length === 0 ||
|
||||||
|
// 0x0 for state vector
|
||||||
|
(bin.length === 1 && bin[0] === 0) ||
|
||||||
|
// 0x00 for update
|
||||||
|
(bin.length === 2 && bin[0] === 0 && bin[1] === 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// REGION: open apis by Op system
|
||||||
|
/**
|
||||||
|
* Get a doc record with latest binary.
|
||||||
|
*/
|
||||||
|
async getDoc(docId: string) {
|
||||||
|
await using _lock = await this.lockDocForUpdate(docId);
|
||||||
|
|
||||||
|
const snapshot = await this.getDocSnapshot(docId);
|
||||||
|
const updates = await this.getDocUpdates(docId);
|
||||||
|
|
||||||
|
if (updates.length) {
|
||||||
|
const { timestamp, bin, editor } = await this.squash(
|
||||||
|
snapshot ? [snapshot, ...updates] : updates
|
||||||
|
);
|
||||||
|
|
||||||
|
const newSnapshot = {
|
||||||
|
spaceId: this.spaceId,
|
||||||
|
docId,
|
||||||
|
bin,
|
||||||
|
timestamp,
|
||||||
|
editor,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.setDocSnapshot(newSnapshot, snapshot);
|
||||||
|
|
||||||
|
// always mark updates as merged unless throws
|
||||||
|
await this.markUpdatesMerged(docId, updates);
|
||||||
|
|
||||||
|
return newSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a yjs binary diff with the given state vector.
|
||||||
|
*/
|
||||||
|
async getDocDiff(docId: string, state?: Uint8Array) {
|
||||||
|
const doc = await this.getDoc(docId);
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
docId,
|
||||||
|
missing: state ? diffUpdate(doc.bin, state) : doc.bin,
|
||||||
|
state: encodeStateVectorFromUpdate(doc.bin),
|
||||||
|
timestamp: doc.timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push updates into storage
|
||||||
|
*/
|
||||||
|
abstract pushDocUpdate(update: DocUpdate): Promise<DocClock>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all docs timestamps info. especially for useful in sync process.
|
||||||
|
*/
|
||||||
|
abstract getDocTimestamps(after?: Date): Promise<DocClocks>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a specific doc data with all snapshots and updates
|
||||||
|
*/
|
||||||
|
abstract deleteDoc(docId: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe on doc updates emitted from storage itself.
|
||||||
|
*
|
||||||
|
* NOTE:
|
||||||
|
*
|
||||||
|
* There is not always update emitted from storage itself.
|
||||||
|
*
|
||||||
|
* For example, in Sqlite storage, the update will only come from user's updating on docs,
|
||||||
|
* in other words, the update will never somehow auto generated in storage internally.
|
||||||
|
*
|
||||||
|
* But for Cloud storage, there will be updates broadcasted from other clients,
|
||||||
|
* so the storage will emit updates to notify the client to integrate them.
|
||||||
|
*/
|
||||||
|
subscribeDocUpdate(callback: (update: DocRecord) => void) {
|
||||||
|
this.event.on('update', callback);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
this.event.off('update', callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// ENDREGION
|
||||||
|
|
||||||
|
// REGION: api for internal usage
|
||||||
|
protected on(
|
||||||
|
event: 'update',
|
||||||
|
callback: (update: DocRecord) => void
|
||||||
|
): () => void;
|
||||||
|
protected on(
|
||||||
|
event: 'snapshot',
|
||||||
|
callback: (snapshot: DocRecord, prevSnapshot: DocRecord | null) => void
|
||||||
|
): () => void;
|
||||||
|
protected on(event: string, callback: (...args: any[]) => void): () => void {
|
||||||
|
this.event.on(event, callback);
|
||||||
|
return () => {
|
||||||
|
this.event.off(event, callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected emit(event: 'update', update: DocRecord): void;
|
||||||
|
protected emit(
|
||||||
|
event: 'snapshot',
|
||||||
|
snapshot: DocRecord,
|
||||||
|
prevSnapshot: DocRecord | null
|
||||||
|
): void;
|
||||||
|
protected emit(event: string, ...args: any[]): void {
|
||||||
|
this.event.emit(event, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected off(event: string, callback: (...args: any[]) => void): void {
|
||||||
|
this.event.off(event, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a doc snapshot from storage
|
||||||
|
*/
|
||||||
|
protected abstract getDocSnapshot(docId: string): Promise<DocRecord | null>;
|
||||||
|
/**
|
||||||
|
* Set the doc snapshot into storage
|
||||||
|
*
|
||||||
|
* @safety
|
||||||
|
* be careful when implementing this method.
|
||||||
|
*
|
||||||
|
* It might be called with outdated snapshot when running in multi-thread environment.
|
||||||
|
*
|
||||||
|
* A common solution is update the snapshot record is DB only when the coming one's timestamp is newer.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* await using _lock = await this.lockDocForUpdate(docId);
|
||||||
|
* // set snapshot
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
protected abstract setDocSnapshot(
|
||||||
|
snapshot: DocRecord,
|
||||||
|
prevSnapshot: DocRecord | null
|
||||||
|
): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all updates of a doc that haven't been merged into snapshot.
|
||||||
|
*
|
||||||
|
* Updates queue design exists for a performace concern:
|
||||||
|
* A huge amount of write time will be saved if we don't merge updates into snapshot immediately.
|
||||||
|
* Updates will be merged into snapshot when the latest doc is requested.
|
||||||
|
*/
|
||||||
|
protected abstract getDocUpdates(docId: string): Promise<DocRecord[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark updates as merged into snapshot.
|
||||||
|
*/
|
||||||
|
protected abstract markUpdatesMerged(
|
||||||
|
docId: string,
|
||||||
|
updates: DocRecord[]
|
||||||
|
): Promise<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge doc updates into a single update.
|
||||||
|
*/
|
||||||
|
protected async squash(updates: DocRecord[]): Promise<DocRecord> {
|
||||||
|
const lastUpdate = updates.at(-1);
|
||||||
|
if (!lastUpdate) {
|
||||||
|
throw new Error('No updates to be squashed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// fast return
|
||||||
|
if (updates.length === 1) {
|
||||||
|
return lastUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalUpdate = await this.mergeUpdates(updates.map(u => u.bin));
|
||||||
|
|
||||||
|
return {
|
||||||
|
docId: lastUpdate.docId,
|
||||||
|
bin: finalUpdate,
|
||||||
|
timestamp: lastUpdate.timestamp,
|
||||||
|
editor: lastUpdate.editor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected mergeUpdates(updates: Uint8Array[]) {
|
||||||
|
const merge = this.options?.mergeUpdates ?? mergeUpdates;
|
||||||
|
|
||||||
|
return merge(updates.filter(bin => !this.isEmptyBin(bin)));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async lockDocForUpdate(docId: string): Promise<Lock> {
|
||||||
|
return this.locker.lock(`workspace:${this.spaceId}:update`, docId);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
packages/common/nbstore/src/storage/history.ts
Normal file
122
packages/common/nbstore/src/storage/history.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { noop } from 'lodash-es';
|
||||||
|
import {
|
||||||
|
applyUpdate,
|
||||||
|
Doc,
|
||||||
|
encodeStateAsUpdate,
|
||||||
|
encodeStateVector,
|
||||||
|
UndoManager,
|
||||||
|
} from 'yjs';
|
||||||
|
|
||||||
|
import { type DocRecord, DocStorage, type DocStorageOptions } from './doc';
|
||||||
|
|
||||||
|
export interface HistoryFilter {
|
||||||
|
before?: Date;
|
||||||
|
limit?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListedHistory {
|
||||||
|
userId: string | null;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class HistoricalDocStorage<
|
||||||
|
Options extends DocStorageOptions = DocStorageOptions,
|
||||||
|
> extends DocStorage<Options> {
|
||||||
|
constructor(opts: Options) {
|
||||||
|
super(opts);
|
||||||
|
|
||||||
|
this.on('snapshot', snapshot => {
|
||||||
|
this.createHistory(snapshot.docId, snapshot).catch(noop);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override async setDocSnapshot(
|
||||||
|
snapshot: DocRecord,
|
||||||
|
prevSnapshot: DocRecord | null
|
||||||
|
): Promise<boolean> {
|
||||||
|
const success = await this.upsertDocSnapshot(snapshot, prevSnapshot);
|
||||||
|
if (success) {
|
||||||
|
this.emit('snapshot', snapshot, prevSnapshot);
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the doc snapshot in storage or create a new one if not exists.
|
||||||
|
*
|
||||||
|
* @safety
|
||||||
|
* be careful when implementing this method.
|
||||||
|
*
|
||||||
|
* It might be called with outdated snapshot when running in multi-thread environment.
|
||||||
|
*
|
||||||
|
* A common solution is update the snapshot record is DB only when the coming one's timestamp is newer.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* await using _lock = await this.lockDocForUpdate(docId);
|
||||||
|
* // set snapshot
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
abstract upsertDocSnapshot(
|
||||||
|
snapshot: DocRecord,
|
||||||
|
prevSnapshot: DocRecord | null
|
||||||
|
): Promise<boolean>;
|
||||||
|
|
||||||
|
abstract listHistories(
|
||||||
|
docId: string,
|
||||||
|
filter?: HistoryFilter
|
||||||
|
): Promise<ListedHistory[]>;
|
||||||
|
abstract getHistory(
|
||||||
|
docId: string,
|
||||||
|
timestamp: Date
|
||||||
|
): Promise<DocRecord | null>;
|
||||||
|
abstract deleteHistory(docId: string, timestamp: Date): Promise<void>;
|
||||||
|
|
||||||
|
async rollbackDoc(docId: string, timestamp: Date, editor?: string) {
|
||||||
|
const toSnapshot = await this.getHistory(docId, timestamp);
|
||||||
|
if (!toSnapshot) {
|
||||||
|
throw new Error('Can not find the version to rollback to.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromSnapshot = await this.getDoc(docId);
|
||||||
|
|
||||||
|
if (!fromSnapshot) {
|
||||||
|
throw new Error('Can not find the current version of the doc.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const change = this.generateRevertUpdate(fromSnapshot.bin, toSnapshot.bin);
|
||||||
|
await this.pushDocUpdate({ docId, bin: change, editor });
|
||||||
|
// force create a new history record after rollback
|
||||||
|
await this.createHistory(docId, fromSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// history can only be created upon update pushing.
|
||||||
|
protected abstract createHistory(
|
||||||
|
docId: string,
|
||||||
|
snapshot: DocRecord
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
|
protected generateRevertUpdate(
|
||||||
|
fromNewerBin: Uint8Array,
|
||||||
|
toOlderBin: Uint8Array
|
||||||
|
): Uint8Array {
|
||||||
|
const newerDoc = new Doc();
|
||||||
|
applyUpdate(newerDoc, fromNewerBin);
|
||||||
|
const olderDoc = new Doc();
|
||||||
|
applyUpdate(olderDoc, toOlderBin);
|
||||||
|
|
||||||
|
const newerState = encodeStateVector(newerDoc);
|
||||||
|
const olderState = encodeStateVector(olderDoc);
|
||||||
|
|
||||||
|
const diff = encodeStateAsUpdate(newerDoc, olderState);
|
||||||
|
|
||||||
|
const undoManager = new UndoManager(Array.from(olderDoc.share.values()));
|
||||||
|
|
||||||
|
applyUpdate(olderDoc, diff);
|
||||||
|
|
||||||
|
undoManager.undo();
|
||||||
|
|
||||||
|
return encodeStateAsUpdate(olderDoc, newerState);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
packages/common/nbstore/src/storage/index.ts
Normal file
93
packages/common/nbstore/src/storage/index.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import EventEmitter2 from 'eventemitter2';
|
||||||
|
|
||||||
|
import type { ConnectionStatus } from '../connection';
|
||||||
|
import { type Storage, type StorageType } from '../storage';
|
||||||
|
|
||||||
|
export class SpaceStorage {
|
||||||
|
protected readonly storages: Map<StorageType, Storage> = new Map();
|
||||||
|
private readonly event = new EventEmitter2();
|
||||||
|
private readonly disposables: Set<() => void> = new Set();
|
||||||
|
|
||||||
|
constructor(storages: Storage[] = []) {
|
||||||
|
this.storages = new Map(
|
||||||
|
storages.map(storage => [storage.storageType, storage])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tryGet(type: StorageType) {
|
||||||
|
return this.storages.get(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(type: StorageType) {
|
||||||
|
const storage = this.tryGet(type);
|
||||||
|
|
||||||
|
if (!storage) {
|
||||||
|
throw new Error(`Storage ${type} not registered.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
await Promise.allSettled(
|
||||||
|
Array.from(this.storages.values()).map(async storage => {
|
||||||
|
this.disposables.add(
|
||||||
|
storage.connection.onStatusChanged((status, error) => {
|
||||||
|
this.event.emit('connection', {
|
||||||
|
storage: storage.storageType,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await storage.connect();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect() {
|
||||||
|
await Promise.allSettled(
|
||||||
|
Array.from(this.storages.values()).map(async storage => {
|
||||||
|
await storage.disconnect();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
on(
|
||||||
|
event: 'connection',
|
||||||
|
cb: (payload: {
|
||||||
|
storage: StorageType;
|
||||||
|
status: ConnectionStatus;
|
||||||
|
error?: Error;
|
||||||
|
}) => void
|
||||||
|
): () => void {
|
||||||
|
this.event.on(event, cb);
|
||||||
|
return () => {
|
||||||
|
this.event.off(event, cb);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
off(
|
||||||
|
event: 'connection',
|
||||||
|
cb: (payload: {
|
||||||
|
storage: StorageType;
|
||||||
|
status: ConnectionStatus;
|
||||||
|
error?: Error;
|
||||||
|
}) => void
|
||||||
|
): void {
|
||||||
|
this.event.off(event, cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy() {
|
||||||
|
await this.disconnect();
|
||||||
|
this.disposables.forEach(disposable => disposable());
|
||||||
|
this.event.removeAllListeners();
|
||||||
|
this.storages.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from './blob';
|
||||||
|
export * from './doc';
|
||||||
|
export * from './history';
|
||||||
|
export * from './storage';
|
||||||
|
export * from './sync';
|
||||||
44
packages/common/nbstore/src/storage/lock.ts
Normal file
44
packages/common/nbstore/src/storage/lock.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
export interface Locker {
|
||||||
|
lock(domain: string, resource: string): Promise<Lock>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SingletonLocker implements Locker {
|
||||||
|
lockedResource = new Map<string, Lock>();
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
async lock(domain: string, resource: string) {
|
||||||
|
const key = `${domain}:${resource}`;
|
||||||
|
let lock = this.lockedResource.get(key);
|
||||||
|
|
||||||
|
if (!lock) {
|
||||||
|
lock = new Lock();
|
||||||
|
this.lockedResource.set(key, lock);
|
||||||
|
}
|
||||||
|
|
||||||
|
await lock.acquire();
|
||||||
|
|
||||||
|
return lock;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Lock {
|
||||||
|
private inner: Promise<void> = Promise.resolve();
|
||||||
|
private release: () => void = () => {};
|
||||||
|
|
||||||
|
async acquire() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
let release: () => void = null!;
|
||||||
|
const nextLock = new Promise<void>(resolve => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.inner;
|
||||||
|
this.inner = nextLock;
|
||||||
|
this.release = release;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.asyncDispose]() {
|
||||||
|
this.release();
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
37
packages/common/nbstore/src/storage/storage.ts
Normal file
37
packages/common/nbstore/src/storage/storage.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Connection } from '../connection';
|
||||||
|
|
||||||
|
export type SpaceType = 'workspace' | 'userspace';
|
||||||
|
export type StorageType = 'blob' | 'doc' | 'sync';
|
||||||
|
|
||||||
|
export interface StorageOptions {
|
||||||
|
peer: string;
|
||||||
|
type: SpaceType;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class Storage<Opts extends StorageOptions = StorageOptions> {
|
||||||
|
abstract readonly storageType: StorageType;
|
||||||
|
abstract readonly connection: Connection;
|
||||||
|
|
||||||
|
get peer() {
|
||||||
|
return this.options.peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
get spaceType() {
|
||||||
|
return this.options.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
get spaceId() {
|
||||||
|
return this.options.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(public readonly options: Opts) {}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
await this.connection.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect() {
|
||||||
|
await this.connection.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
packages/common/nbstore/src/storage/sync.ts
Normal file
16
packages/common/nbstore/src/storage/sync.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { DocClock, DocClocks } from './doc';
|
||||||
|
import { Storage, type StorageOptions } from './storage';
|
||||||
|
|
||||||
|
export interface SyncStorageOptions extends StorageOptions {}
|
||||||
|
|
||||||
|
export abstract class SyncStorage<
|
||||||
|
Opts extends SyncStorageOptions = SyncStorageOptions,
|
||||||
|
> extends Storage<Opts> {
|
||||||
|
override readonly storageType = 'sync';
|
||||||
|
|
||||||
|
abstract getPeerClocks(peer: string): Promise<DocClocks>;
|
||||||
|
abstract setPeerClock(peer: string, clock: DocClock): Promise<void>;
|
||||||
|
abstract getPeerPushedClocks(peer: string): Promise<DocClocks>;
|
||||||
|
abstract setPeerPushedClock(peer: string, clock: DocClock): Promise<void>;
|
||||||
|
abstract clearClocks(): Promise<void>;
|
||||||
|
}
|
||||||
20
packages/common/nbstore/tsconfig.json
Normal file
20
packages/common/nbstore/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"include": ["./src"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "lib"
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "../../frontend/graphql"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../../frontend/electron-api"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../infra"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"native",
|
"native",
|
||||||
"templates",
|
"templates",
|
||||||
"debug",
|
"debug",
|
||||||
"storage",
|
"nbstore",
|
||||||
"infra"
|
"infra"
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -72,7 +72,8 @@
|
|||||||
"@affine/native/*": ["./packages/frontend/native/*"],
|
"@affine/native/*": ["./packages/frontend/native/*"],
|
||||||
"@affine/server-native": ["./packages/backend/native/index.d.ts"],
|
"@affine/server-native": ["./packages/backend/native/index.d.ts"],
|
||||||
// Development only
|
// Development only
|
||||||
"@affine/electron/*": ["./packages/frontend/apps/electron/src/*"]
|
"@affine/electron/*": ["./packages/frontend/apps/electron/src/*"],
|
||||||
|
"@affine/nbstore": ["./packages/common/nbstore/src"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [],
|
"include": [],
|
||||||
@@ -131,6 +132,9 @@
|
|||||||
{
|
{
|
||||||
"path": "./packages/common/infra"
|
"path": "./packages/common/infra"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "./packages/common/nbstore"
|
||||||
|
},
|
||||||
// Tools
|
// Tools
|
||||||
{
|
{
|
||||||
"path": "./tools/cli"
|
"path": "./tools/cli"
|
||||||
|
|||||||
12
yarn.lock
12
yarn.lock
@@ -723,6 +723,18 @@ __metadata:
|
|||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
|
"@affine/nbstore@workspace:packages/common/nbstore":
|
||||||
|
version: 0.0.0-use.local
|
||||||
|
resolution: "@affine/nbstore@workspace:packages/common/nbstore"
|
||||||
|
dependencies:
|
||||||
|
"@toeverything/infra": "workspace:*"
|
||||||
|
eventemitter2: "npm:^6.4.9"
|
||||||
|
lodash-es: "npm:^4.17.21"
|
||||||
|
rxjs: "npm:^7.8.1"
|
||||||
|
yjs: "patch:yjs@npm%3A13.6.18#~/.yarn/patches/yjs-npm-13.6.18-ad0d5f7c43.patch"
|
||||||
|
languageName: unknown
|
||||||
|
linkType: soft
|
||||||
|
|
||||||
"@affine/playstore-auto-bump@workspace:tools/playstore-auto-bump":
|
"@affine/playstore-auto-bump@workspace:tools/playstore-auto-bump":
|
||||||
version: 0.0.0-use.local
|
version: 0.0.0-use.local
|
||||||
resolution: "@affine/playstore-auto-bump@workspace:tools/playstore-auto-bump"
|
resolution: "@affine/playstore-auto-bump@workspace:tools/playstore-auto-bump"
|
||||||
|
|||||||
Reference in New Issue
Block a user