mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-18 18:46:19 +08:00
refactor(workspace): split workspace interface and implementation (#5463)
@affine/workspace -> (@affine/workspace, @affine/workspace-impl)
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
export enum SyncEngineStep {
|
||||
Stopped = 0,
|
||||
Syncing = 1,
|
||||
Synced = 2,
|
||||
}
|
||||
|
||||
export enum SyncPeerStep {
|
||||
Stopped = 0,
|
||||
Retrying = 1,
|
||||
LoadingRootDoc = 2,
|
||||
LoadingSubDoc = 3,
|
||||
Loaded = 4.5,
|
||||
Syncing = 5,
|
||||
Synced = 6,
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { Slot } from '@blocksuite/global/utils';
|
||||
import type { Doc } from 'yjs';
|
||||
|
||||
import { SharedPriorityTarget } from '../../utils/async-queue';
|
||||
import { MANUALLY_STOP, throwIfAborted } from '../../utils/throw-if-aborted';
|
||||
import { SyncEngineStep, SyncPeerStep } from './consts';
|
||||
import { SyncPeer, type SyncPeerStatus } from './peer';
|
||||
import type { SyncStorage } from './storage';
|
||||
|
||||
export interface SyncEngineStatus {
|
||||
step: SyncEngineStep;
|
||||
local: SyncPeerStatus | null;
|
||||
remotes: (SyncPeerStatus | null)[];
|
||||
retrying: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* # SyncEngine
|
||||
*
|
||||
* ```
|
||||
* ┌────────────┐
|
||||
* │ SyncEngine │
|
||||
* └─────┬──────┘
|
||||
* │
|
||||
* ▼
|
||||
* ┌────────────┐
|
||||
* │ SyncPeer │
|
||||
* ┌─────────┤ local ├─────────┐
|
||||
* │ └─────┬──────┘ │
|
||||
* │ │ │
|
||||
* ▼ ▼ ▼
|
||||
* ┌────────────┐ ┌────────────┐ ┌────────────┐
|
||||
* │ SyncPeer │ │ SyncPeer │ │ SyncPeer │
|
||||
* │ Remote │ │ Remote │ │ Remote │
|
||||
* └────────────┘ └────────────┘ └────────────┘
|
||||
* ```
|
||||
*
|
||||
* Sync engine manage sync peers
|
||||
*
|
||||
* Sync steps:
|
||||
* 1. start local sync
|
||||
* 2. wait for local sync complete
|
||||
* 3. start remote sync
|
||||
* 4. continuously sync local and remote
|
||||
*/
|
||||
export class SyncEngine {
|
||||
get rootDocId() {
|
||||
return this.rootDoc.guid;
|
||||
}
|
||||
|
||||
logger = new DebugLogger('affine:sync-engine:' + this.rootDocId);
|
||||
private _status: SyncEngineStatus;
|
||||
onStatusChange = new Slot<SyncEngineStatus>();
|
||||
private set status(s: SyncEngineStatus) {
|
||||
this.logger.debug('status change', s);
|
||||
this._status = s;
|
||||
this.onStatusChange.emit(s);
|
||||
}
|
||||
|
||||
priorityTarget = new SharedPriorityTarget();
|
||||
|
||||
get status() {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
private abort = new AbortController();
|
||||
|
||||
constructor(
|
||||
private readonly rootDoc: Doc,
|
||||
private readonly local: SyncStorage,
|
||||
private readonly remotes: SyncStorage[]
|
||||
) {
|
||||
this._status = {
|
||||
step: SyncEngineStep.Stopped,
|
||||
local: null,
|
||||
remotes: remotes.map(() => null),
|
||||
retrying: false,
|
||||
};
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.status.step !== SyncEngineStep.Stopped) {
|
||||
this.forceStop();
|
||||
}
|
||||
this.abort = new AbortController();
|
||||
|
||||
this.sync(this.abort.signal).catch(err => {
|
||||
// should never reach here
|
||||
this.logger.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
canGracefulStop() {
|
||||
return !!this.status.local && this.status.local.pendingPushUpdates === 0;
|
||||
}
|
||||
|
||||
async waitForGracefulStop(abort?: AbortSignal) {
|
||||
await Promise.race([
|
||||
new Promise((_, reject) => {
|
||||
if (abort?.aborted) {
|
||||
reject(abort?.reason);
|
||||
}
|
||||
abort?.addEventListener('abort', () => {
|
||||
reject(abort.reason);
|
||||
});
|
||||
}),
|
||||
new Promise<void>(resolve => {
|
||||
this.onStatusChange.on(() => {
|
||||
if (this.canGracefulStop()) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}),
|
||||
]);
|
||||
throwIfAborted(abort);
|
||||
this.forceStop();
|
||||
}
|
||||
|
||||
forceStop() {
|
||||
this.abort.abort(MANUALLY_STOP);
|
||||
this._status = {
|
||||
step: SyncEngineStep.Stopped,
|
||||
local: null,
|
||||
remotes: this.remotes.map(() => null),
|
||||
retrying: false,
|
||||
};
|
||||
}
|
||||
|
||||
// main sync process, should never return until abort
|
||||
async sync(signal: AbortSignal) {
|
||||
const state: {
|
||||
localPeer: SyncPeer | null;
|
||||
remotePeers: (SyncPeer | null)[];
|
||||
} = {
|
||||
localPeer: null,
|
||||
remotePeers: this.remotes.map(() => null),
|
||||
};
|
||||
|
||||
const cleanUp: (() => void)[] = [];
|
||||
try {
|
||||
// Step 1: start local sync peer
|
||||
state.localPeer = new SyncPeer(
|
||||
this.rootDoc,
|
||||
this.local,
|
||||
this.priorityTarget
|
||||
);
|
||||
|
||||
cleanUp.push(
|
||||
state.localPeer.onStatusChange.on(() => {
|
||||
if (!signal.aborted)
|
||||
this.updateSyncingState(state.localPeer, state.remotePeers);
|
||||
}).dispose
|
||||
);
|
||||
|
||||
this.updateSyncingState(state.localPeer, state.remotePeers);
|
||||
|
||||
// Step 2: wait for local sync complete
|
||||
await state.localPeer.waitForLoaded(signal);
|
||||
|
||||
// Step 3: start remote sync peer
|
||||
state.remotePeers = this.remotes.map(remote => {
|
||||
const peer = new SyncPeer(this.rootDoc, remote, this.priorityTarget);
|
||||
cleanUp.push(
|
||||
peer.onStatusChange.on(() => {
|
||||
if (!signal.aborted)
|
||||
this.updateSyncingState(state.localPeer, state.remotePeers);
|
||||
}).dispose
|
||||
);
|
||||
return peer;
|
||||
});
|
||||
|
||||
this.updateSyncingState(state.localPeer, state.remotePeers);
|
||||
|
||||
// Step 4: continuously sync local and remote
|
||||
|
||||
// wait for abort
|
||||
await new Promise((_, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason);
|
||||
}
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(signal.reason);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error === MANUALLY_STOP || signal.aborted) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
// stop peers
|
||||
state.localPeer?.stop();
|
||||
for (const remotePeer of state.remotePeers) {
|
||||
remotePeer?.stop();
|
||||
}
|
||||
for (const clean of cleanUp) {
|
||||
clean();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateSyncingState(local: SyncPeer | null, remotes: (SyncPeer | null)[]) {
|
||||
let step = SyncEngineStep.Synced;
|
||||
const allPeer = [local, ...remotes];
|
||||
for (const peer of allPeer) {
|
||||
if (!peer || peer.status.step !== SyncPeerStep.Synced) {
|
||||
step = SyncEngineStep.Syncing;
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.status = {
|
||||
step,
|
||||
local: local?.status ?? null,
|
||||
remotes: remotes.map(peer => peer?.status ?? null),
|
||||
retrying: allPeer.some(
|
||||
peer => peer?.status.step === SyncPeerStep.Retrying
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async waitForSynced(abort?: AbortSignal) {
|
||||
if (this.status.step === SyncEngineStep.Synced) {
|
||||
return;
|
||||
} else {
|
||||
return Promise.race([
|
||||
new Promise<void>(resolve => {
|
||||
this.onStatusChange.on(status => {
|
||||
if (status.step === SyncEngineStep.Synced) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}),
|
||||
new Promise((_, reject) => {
|
||||
if (abort?.aborted) {
|
||||
reject(abort?.reason);
|
||||
}
|
||||
abort?.addEventListener('abort', () => {
|
||||
reject(abort.reason);
|
||||
});
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async waitForLoadedRootDoc(abort?: AbortSignal) {
|
||||
function isLoadedRootDoc(status: SyncEngineStatus) {
|
||||
return ![status.local, ...status.remotes].some(
|
||||
peer => !peer || peer.step <= SyncPeerStep.LoadingRootDoc
|
||||
);
|
||||
}
|
||||
if (isLoadedRootDoc(this.status)) {
|
||||
return;
|
||||
} else {
|
||||
return Promise.race([
|
||||
new Promise<void>(resolve => {
|
||||
this.onStatusChange.on(status => {
|
||||
if (isLoadedRootDoc(status)) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}),
|
||||
new Promise((_, reject) => {
|
||||
if (abort?.aborted) {
|
||||
reject(abort?.reason);
|
||||
}
|
||||
abort?.addEventListener('abort', () => {
|
||||
reject(abort.reason);
|
||||
});
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
setPriorityRule(target: ((id: string) => boolean) | null) {
|
||||
this.priorityTarget.priorityRule = target;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
*
|
||||
* **SyncEngine**
|
||||
*
|
||||
* Manages one local storage and multiple remote storages.
|
||||
*
|
||||
* Responsible for creating SyncPeers for synchronization, following the local-first strategy.
|
||||
*
|
||||
* **SyncPeer**
|
||||
*
|
||||
* Responsible for synchronizing a single storage with Y.Doc.
|
||||
*
|
||||
* Carries the main synchronization logic.
|
||||
*
|
||||
*/
|
||||
|
||||
export * from './consts';
|
||||
export * from './engine';
|
||||
export * from './peer';
|
||||
export * from './storage';
|
||||
@@ -0,0 +1,444 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { Slot } from '@blocksuite/global/utils';
|
||||
import { isEqual } from '@blocksuite/global/utils';
|
||||
import type { Doc } from 'yjs';
|
||||
import { applyUpdate, encodeStateAsUpdate, encodeStateVector } from 'yjs';
|
||||
|
||||
import {
|
||||
PriorityAsyncQueue,
|
||||
SharedPriorityTarget,
|
||||
} from '../../utils/async-queue';
|
||||
import { mergeUpdates } from '../../utils/merge-updates';
|
||||
import { MANUALLY_STOP, throwIfAborted } from '../../utils/throw-if-aborted';
|
||||
import { SyncPeerStep } from './consts';
|
||||
import type { SyncStorage } from './storage';
|
||||
|
||||
export interface SyncPeerStatus {
|
||||
step: SyncPeerStep;
|
||||
totalDocs: number;
|
||||
loadedDocs: number;
|
||||
pendingPullUpdates: number;
|
||||
pendingPushUpdates: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* # SyncPeer
|
||||
* A SyncPeer is responsible for syncing one Storage with one Y.Doc and its subdocs.
|
||||
*
|
||||
* ```
|
||||
* ┌─────┐
|
||||
* │Start│
|
||||
* └──┬──┘
|
||||
* │
|
||||
* ┌──────┐ ┌─────▼──────┐ ┌────┐
|
||||
* │listen◄─────┤pull rootdoc│ │peer│
|
||||
* └──┬───┘ └─────┬──────┘ └──┬─┘
|
||||
* │ │ onLoad() │
|
||||
* ┌──▼───┐ ┌─────▼──────┐ ┌────▼────┐
|
||||
* │listen◄─────┤pull subdocs│ │subscribe│
|
||||
* └──┬───┘ └─────┬──────┘ └────┬────┘
|
||||
* │ │ onReady() │
|
||||
* ┌──▼──┐ ┌─────▼───────┐ ┌──▼──┐
|
||||
* │queue├──────►apply updates◄───────┤queue│
|
||||
* └─────┘ └─────────────┘ └─────┘
|
||||
* ```
|
||||
*
|
||||
* listen: listen for updates from ydoc, typically from user modifications.
|
||||
* subscribe: listen for updates from storage, typically from other users.
|
||||
*
|
||||
*/
|
||||
export class SyncPeer {
|
||||
private _status: SyncPeerStatus = {
|
||||
step: SyncPeerStep.LoadingRootDoc,
|
||||
totalDocs: 1,
|
||||
loadedDocs: 0,
|
||||
pendingPullUpdates: 0,
|
||||
pendingPushUpdates: 0,
|
||||
};
|
||||
onStatusChange = new Slot<SyncPeerStatus>();
|
||||
readonly abort = new AbortController();
|
||||
get name() {
|
||||
return this.storage.name;
|
||||
}
|
||||
logger = new DebugLogger('affine:sync-peer:' + this.name);
|
||||
|
||||
constructor(
|
||||
private readonly rootDoc: Doc,
|
||||
private readonly storage: SyncStorage,
|
||||
private readonly priorityTarget = new SharedPriorityTarget()
|
||||
) {
|
||||
this.logger.debug('peer start');
|
||||
|
||||
this.syncRetryLoop(this.abort.signal).catch(err => {
|
||||
// should not reach here
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
private set status(s: SyncPeerStatus) {
|
||||
if (!isEqual(s, this._status)) {
|
||||
this.logger.debug('status change', s);
|
||||
this._status = s;
|
||||
this.onStatusChange.emit(s);
|
||||
}
|
||||
}
|
||||
|
||||
get status() {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
/**
|
||||
* stop sync
|
||||
*
|
||||
* SyncPeer is one-time use, this peer should be discarded after call stop().
|
||||
*/
|
||||
stop() {
|
||||
this.logger.debug('peer stop');
|
||||
this.abort.abort(MANUALLY_STOP);
|
||||
}
|
||||
|
||||
/**
|
||||
* auto retry after 5 seconds if sync failed
|
||||
*/
|
||||
async syncRetryLoop(abort: AbortSignal) {
|
||||
while (abort.aborted === false) {
|
||||
try {
|
||||
await this.sync(abort);
|
||||
} catch (err) {
|
||||
if (err === MANUALLY_STOP || abort.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.error('sync error', err);
|
||||
}
|
||||
try {
|
||||
this.logger.error('retry after 5 seconds');
|
||||
this.status = {
|
||||
step: SyncPeerStep.Retrying,
|
||||
totalDocs: 1,
|
||||
loadedDocs: 0,
|
||||
pendingPullUpdates: 0,
|
||||
pendingPushUpdates: 0,
|
||||
};
|
||||
await Promise.race([
|
||||
new Promise<void>(resolve => {
|
||||
setTimeout(resolve, 5 * 1000);
|
||||
}),
|
||||
new Promise((_, reject) => {
|
||||
// exit if manually stopped
|
||||
if (abort.aborted) {
|
||||
reject(abort.reason);
|
||||
}
|
||||
abort.addEventListener('abort', () => {
|
||||
reject(abort.reason);
|
||||
});
|
||||
}),
|
||||
]);
|
||||
} catch (err) {
|
||||
if (err === MANUALLY_STOP || abort.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// should never reach here
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly state: {
|
||||
connectedDocs: Map<string, Doc>;
|
||||
pushUpdatesQueue: PriorityAsyncQueue<{
|
||||
id: string;
|
||||
data: Uint8Array[];
|
||||
}>;
|
||||
pushingUpdate: boolean;
|
||||
pullUpdatesQueue: PriorityAsyncQueue<{
|
||||
id: string;
|
||||
data: Uint8Array;
|
||||
}>;
|
||||
subdocLoading: boolean;
|
||||
subdocsLoadQueue: PriorityAsyncQueue<{ id: string; doc: Doc }>;
|
||||
} = {
|
||||
connectedDocs: new Map(),
|
||||
pushUpdatesQueue: new PriorityAsyncQueue([], this.priorityTarget),
|
||||
pushingUpdate: false,
|
||||
pullUpdatesQueue: new PriorityAsyncQueue([], this.priorityTarget),
|
||||
subdocLoading: false,
|
||||
subdocsLoadQueue: new PriorityAsyncQueue([], this.priorityTarget),
|
||||
};
|
||||
|
||||
initState() {
|
||||
this.state.connectedDocs.clear();
|
||||
this.state.pushUpdatesQueue.clear();
|
||||
this.state.pullUpdatesQueue.clear();
|
||||
this.state.subdocsLoadQueue.clear();
|
||||
this.state.pushingUpdate = false;
|
||||
this.state.subdocLoading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* main synchronization logic
|
||||
*/
|
||||
async sync(abortOuter: AbortSignal) {
|
||||
this.initState();
|
||||
const abortInner = new AbortController();
|
||||
|
||||
abortOuter.addEventListener('abort', reason => {
|
||||
abortInner.abort(reason);
|
||||
});
|
||||
|
||||
let dispose: (() => void) | null = null;
|
||||
try {
|
||||
this.reportSyncStatus();
|
||||
|
||||
// start listen storage updates
|
||||
dispose = await this.storage.subscribe(
|
||||
this.handleStorageUpdates,
|
||||
reason => {
|
||||
// abort if storage disconnect, should trigger retry loop
|
||||
abortInner.abort('subscribe disconnect:' + reason);
|
||||
}
|
||||
);
|
||||
throwIfAborted(abortInner.signal);
|
||||
|
||||
// Step 1: load root doc
|
||||
await this.connectDoc(this.rootDoc, abortInner.signal);
|
||||
|
||||
// Step 2: load subdocs
|
||||
this.state.subdocsLoadQueue.push(
|
||||
...Array.from(this.rootDoc.getSubdocs()).map(doc => ({
|
||||
id: doc.guid,
|
||||
doc,
|
||||
}))
|
||||
);
|
||||
this.reportSyncStatus();
|
||||
|
||||
this.rootDoc.on('subdocs', this.handleSubdocsUpdate);
|
||||
|
||||
// Finally: start sync
|
||||
await Promise.all([
|
||||
// load subdocs
|
||||
(async () => {
|
||||
while (throwIfAborted(abortInner.signal)) {
|
||||
const subdoc = await this.state.subdocsLoadQueue.next(
|
||||
abortInner.signal
|
||||
);
|
||||
this.state.subdocLoading = true;
|
||||
this.reportSyncStatus();
|
||||
await this.connectDoc(subdoc.doc, abortInner.signal);
|
||||
this.state.subdocLoading = false;
|
||||
this.reportSyncStatus();
|
||||
}
|
||||
})(),
|
||||
// pull updates
|
||||
(async () => {
|
||||
while (throwIfAborted(abortInner.signal)) {
|
||||
const { id, data } = await this.state.pullUpdatesQueue.next(
|
||||
abortInner.signal
|
||||
);
|
||||
// don't apply empty data or Uint8Array([0, 0])
|
||||
if (
|
||||
!(
|
||||
data.byteLength === 0 ||
|
||||
(data.byteLength === 2 && data[0] === 0 && data[1] === 0)
|
||||
)
|
||||
) {
|
||||
const subdoc = this.state.connectedDocs.get(id);
|
||||
if (subdoc) {
|
||||
applyUpdate(subdoc, data, this.name);
|
||||
}
|
||||
}
|
||||
this.reportSyncStatus();
|
||||
}
|
||||
})(),
|
||||
// push updates
|
||||
(async () => {
|
||||
while (throwIfAborted(abortInner.signal)) {
|
||||
const { id, data } = await this.state.pushUpdatesQueue.next(
|
||||
abortInner.signal
|
||||
);
|
||||
this.state.pushingUpdate = true;
|
||||
this.reportSyncStatus();
|
||||
|
||||
const merged = mergeUpdates(data);
|
||||
|
||||
// don't push empty data or Uint8Array([0, 0])
|
||||
if (
|
||||
!(
|
||||
merged.byteLength === 0 ||
|
||||
(merged.byteLength === 2 && merged[0] === 0 && merged[1] === 0)
|
||||
)
|
||||
) {
|
||||
await this.storage.push(id, merged);
|
||||
}
|
||||
|
||||
this.state.pushingUpdate = false;
|
||||
this.reportSyncStatus();
|
||||
}
|
||||
})(),
|
||||
]);
|
||||
} finally {
|
||||
dispose?.();
|
||||
for (const docs of this.state.connectedDocs.values()) {
|
||||
this.disconnectDoc(docs);
|
||||
}
|
||||
this.rootDoc.off('subdocs', this.handleSubdocsUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
async connectDoc(doc: Doc, abort: AbortSignal) {
|
||||
const { data: docData, state: inStorageState } =
|
||||
(await this.storage.pull(doc.guid, encodeStateVector(doc))) ?? {};
|
||||
throwIfAborted(abort);
|
||||
|
||||
if (docData) {
|
||||
applyUpdate(doc, docData, 'load');
|
||||
}
|
||||
|
||||
// diff root doc and in-storage, save updates to pendingUpdates
|
||||
this.state.pushUpdatesQueue.push({
|
||||
id: doc.guid,
|
||||
data: [encodeStateAsUpdate(doc, inStorageState)],
|
||||
});
|
||||
|
||||
this.state.connectedDocs.set(doc.guid, doc);
|
||||
|
||||
// start listen root doc changes
|
||||
doc.on('update', this.handleYDocUpdates);
|
||||
|
||||
// mark rootDoc as loaded
|
||||
doc.emit('sync', [true]);
|
||||
|
||||
this.reportSyncStatus();
|
||||
}
|
||||
|
||||
disconnectDoc(doc: Doc) {
|
||||
doc.off('update', this.handleYDocUpdates);
|
||||
this.state.connectedDocs.delete(doc.guid);
|
||||
this.reportSyncStatus();
|
||||
}
|
||||
|
||||
// handle updates from ydoc
|
||||
handleYDocUpdates = (update: Uint8Array, origin: string, doc: Doc) => {
|
||||
// don't push updates from storage
|
||||
if (origin === this.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exist = this.state.pushUpdatesQueue.find(({ id }) => id === doc.guid);
|
||||
if (exist) {
|
||||
exist.data.push(update);
|
||||
} else {
|
||||
this.state.pushUpdatesQueue.push({
|
||||
id: doc.guid,
|
||||
data: [update],
|
||||
});
|
||||
}
|
||||
|
||||
this.reportSyncStatus();
|
||||
};
|
||||
|
||||
// handle subdocs changes, append new subdocs to queue, remove subdocs from queue
|
||||
handleSubdocsUpdate = ({
|
||||
added,
|
||||
removed,
|
||||
}: {
|
||||
added: Set<Doc>;
|
||||
removed: Set<Doc>;
|
||||
}) => {
|
||||
for (const subdoc of added) {
|
||||
this.state.subdocsLoadQueue.push({ id: subdoc.guid, doc: subdoc });
|
||||
}
|
||||
|
||||
for (const subdoc of removed) {
|
||||
this.disconnectDoc(subdoc);
|
||||
this.state.subdocsLoadQueue.remove(doc => doc.doc === subdoc);
|
||||
}
|
||||
this.reportSyncStatus();
|
||||
};
|
||||
|
||||
// handle updates from storage
|
||||
handleStorageUpdates = (id: string, data: Uint8Array) => {
|
||||
this.state.pullUpdatesQueue.push({
|
||||
id,
|
||||
data,
|
||||
});
|
||||
this.reportSyncStatus();
|
||||
};
|
||||
|
||||
reportSyncStatus() {
|
||||
let step;
|
||||
if (this.state.connectedDocs.size === 0) {
|
||||
step = SyncPeerStep.LoadingRootDoc;
|
||||
} else if (this.state.subdocsLoadQueue.length || this.state.subdocLoading) {
|
||||
step = SyncPeerStep.LoadingSubDoc;
|
||||
} else if (
|
||||
this.state.pullUpdatesQueue.length ||
|
||||
this.state.pushUpdatesQueue.length ||
|
||||
this.state.pushingUpdate
|
||||
) {
|
||||
step = SyncPeerStep.Syncing;
|
||||
} else {
|
||||
step = SyncPeerStep.Synced;
|
||||
}
|
||||
|
||||
this.status = {
|
||||
step: step,
|
||||
totalDocs:
|
||||
this.state.connectedDocs.size + this.state.subdocsLoadQueue.length,
|
||||
loadedDocs: this.state.connectedDocs.size,
|
||||
pendingPullUpdates:
|
||||
this.state.pullUpdatesQueue.length + (this.state.subdocLoading ? 1 : 0),
|
||||
pendingPushUpdates:
|
||||
this.state.pushUpdatesQueue.length + (this.state.pushingUpdate ? 1 : 0),
|
||||
};
|
||||
}
|
||||
|
||||
async waitForSynced(abort?: AbortSignal) {
|
||||
if (this.status.step >= SyncPeerStep.Synced) {
|
||||
return;
|
||||
} else {
|
||||
return Promise.race([
|
||||
new Promise<void>(resolve => {
|
||||
this.onStatusChange.on(status => {
|
||||
if (status.step >= SyncPeerStep.Synced) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}),
|
||||
new Promise((_, reject) => {
|
||||
if (abort?.aborted) {
|
||||
reject(abort?.reason);
|
||||
}
|
||||
abort?.addEventListener('abort', () => {
|
||||
reject(abort.reason);
|
||||
});
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async waitForLoaded(abort?: AbortSignal) {
|
||||
if (this.status.step > SyncPeerStep.Loaded) {
|
||||
return;
|
||||
} else {
|
||||
return Promise.race([
|
||||
new Promise<void>(resolve => {
|
||||
this.onStatusChange.on(status => {
|
||||
if (status.step > SyncPeerStep.Loaded) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}),
|
||||
new Promise((_, reject) => {
|
||||
if (abort?.aborted) {
|
||||
reject(abort?.reason);
|
||||
}
|
||||
abort?.addEventListener('abort', () => {
|
||||
reject(abort.reason);
|
||||
});
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface SyncStorage {
|
||||
/**
|
||||
* for debug
|
||||
*/
|
||||
name: string;
|
||||
|
||||
pull(
|
||||
docId: string,
|
||||
state: Uint8Array
|
||||
): Promise<{ data: Uint8Array; state?: Uint8Array } | null>;
|
||||
push(docId: string, data: Uint8Array): Promise<void>;
|
||||
|
||||
/**
|
||||
* Subscribe to updates from peer
|
||||
*
|
||||
* @param cb callback to handle updates
|
||||
* @param disconnect callback to handle disconnect, reason can be something like 'network-error'
|
||||
*
|
||||
* @returns unsubscribe function
|
||||
*/
|
||||
subscribe(
|
||||
cb: (docId: string, data: Uint8Array) => void,
|
||||
disconnect: (reason: string) => void
|
||||
): Promise<() => void>;
|
||||
}
|
||||
Reference in New Issue
Block a user