scrivito
Version:
Scrivito is a professional, yet easy to use SaaS Enterprise Content Management Service, built for digital agencies and medium to large businesses. It is completely maintenance-free, cost-effective, and has unprecedented performance and security.
263 lines (220 loc) • 7.28 kB
text/typescript
import isEmpty from 'lodash-es/isEmpty';
import {
ObjJson,
ObjSpaceId,
cmsRestApi,
getWorkspaceId,
isUnavailableObjJson,
retrieveObj,
} from 'scrivito_sdk/client';
import {
Deferred,
InternalError,
nextTick,
throttle,
} from 'scrivito_sdk/common';
import { isObjReplicationDisabled } from 'scrivito_sdk/data/disable_obj_replication';
import { setObjData } from 'scrivito_sdk/data/obj_data_store';
import {
ObjJsonPatch,
diffObjJson,
patchObjJson,
} from 'scrivito_sdk/data/obj_patch';
import { ObjReplication } from 'scrivito_sdk/data/obj_replication';
import { objReplicationPool } from 'scrivito_sdk/data/obj_replication_pool';
import { addBatchUpdate } from 'scrivito_sdk/state';
export class ObjBackendReplication implements ObjReplication {
private replicationActive: boolean;
private scheduledReplication: boolean;
private currentRequestDeferred?: Deferred<void>;
private nextRequestDeferred?: Deferred<void>;
private performThrottledReplication: () => void;
private localState?: ObjJson;
private backendState?: ObjJson;
private bufferedBackendState?: ObjJson;
constructor(
private readonly objSpaceId: ObjSpaceId,
private readonly objId: string
) {
this.replicationActive = false;
this.scheduledReplication = false;
this.performThrottledReplication = throttle(
() => this.performReplication(),
1000
);
}
async start() {
const data = await retrieveObj(this.objSpaceId, this.objId, 'full');
addBatchUpdate(() => {
this.notifyBackendState(data);
});
}
notifyLocalState(localState: ObjJson) {
if (isObjReplicationDisabled()) return;
if (isEqualState(this.localState, localState)) return;
this.localState = localState;
this.startReplication();
}
notifyBackendState(newBackendState: ObjJson) {
if (!this.localState) {
this.backendState = newBackendState;
this.updateLocalState(newBackendState);
return;
}
const newestKnownBackendState =
this.bufferedBackendState || this.backendState;
if (
!newestKnownBackendState ||
compareStates(newBackendState, newestKnownBackendState) > 0
) {
if (this.replicationActive) {
this.bufferedBackendState = newBackendState;
} else {
if (isUnavailableObjJson(newBackendState)) {
this.updateLocalState(newBackendState);
} else {
this.updateLocalState(
patchObjJson(
this.localState,
diffObjJson(this.backendState, newBackendState)
)
);
}
this.backendState = newBackendState;
}
}
}
async finishSaving(): Promise<void> {
let finishSavingPromise;
if (this.nextRequestDeferred) {
finishSavingPromise = this.nextRequestDeferred.promise;
} else if (this.currentRequestDeferred) {
finishSavingPromise = this.currentRequestDeferred.promise;
} else {
return;
}
return finishSavingPromise;
}
finishReplicating(): never {
// this method is intended for stream replication
// should never be called for instances of this class
throw new InternalError();
}
replicationMessageStream(): never {
// this method is intended for stream replication
// should never be called for instances of this class
throw new InternalError();
}
// For test purposes
getLocalState() {
return this.localState;
}
// For test purposes
getBackendState() {
return this.backendState;
}
private startReplication() {
if (!isEqualState(this.backendState, this.getLocalObjJson())) {
if (!this.replicationActive) {
if (!this.scheduledReplication) {
this.scheduledReplication = true;
this.initDeferredForRequest();
objReplicationPool.writeStarted(this.currentRequestDeferred!.promise);
nextTick(() => this.performThrottledReplication());
}
} else if (!this.nextRequestDeferred) {
this.nextRequestDeferred = new Deferred();
}
} else if (this.nextRequestDeferred) {
this.nextRequestDeferred.resolve();
this.nextRequestDeferred = undefined;
}
}
private async performReplication() {
const localState = this.getLocalObjJson();
this.scheduledReplication = false;
this.replicationActive = true;
try {
const backendState = await this.replicateLocalStateToBackend(localState);
this.handleBackendUpdate(localState, backendState);
this.currentRequestDeferred!.resolve();
this.currentRequestDeferred = undefined;
this.replicationActive = false;
this.startReplication();
} catch (error) {
if (!(error instanceof Error)) throw error;
this.currentRequestDeferred!.reject(error);
this.currentRequestDeferred = undefined;
this.replicationActive = false;
}
}
private async replicateLocalStateToBackend(
localState: ObjJson
): Promise<ObjJson> {
const patch = diffObjJson(this.backendState, localState);
return isEmpty(patch)
? // bang:
// given the localState is not blank, the diff may be empty only if the
// backendState is similar (equal?) to the localState, i.e. not blank
Promise.resolve(this.backendState!)
: this.replicatePatchToBackend(patch);
}
private replicatePatchToBackend(patch: ObjJsonPatch): Promise<ObjJson> {
const id = getWorkspaceId(this.objSpaceId);
if (id === 'published') throw new InternalError();
return cmsRestApi.put(`workspaces/${id}/objs/${this.objId}`, {
obj: patch,
}) as Promise<ObjJson>;
}
private initDeferredForRequest() {
if (this.nextRequestDeferred) {
const currentDeferred = this.nextRequestDeferred;
this.nextRequestDeferred = undefined;
this.currentRequestDeferred = currentDeferred;
} else {
this.currentRequestDeferred = new Deferred();
}
}
private handleBackendUpdate(replicatedState: ObjJson, backendState: ObjJson) {
this.backendState = newerState(backendState, this.bufferedBackendState);
this.bufferedBackendState = undefined;
this.updateLocalState(
patchObjJson(
this.backendState,
diffObjJson(replicatedState, this.getLocalObjJson())
)
);
}
private updateLocalState(localState: ObjJson) {
this.localState = localState;
setObjData(this.objSpaceId, this.objId, localState);
}
private getLocalObjJson(): ObjJson {
if (this.localState === undefined) {
throw new InternalError();
}
return this.localState;
}
// For test purpose only.
isRequestInFlight() {
return this.replicationActive;
}
}
function isEqualState(stateA: ObjJson | undefined, stateB: ObjJson) {
return isEmpty(diffObjJson(stateA, stateB));
}
function newerState(stateA: ObjJson, stateB: ObjJson | undefined) {
if (!stateB) return stateA;
if (compareStates(stateA, stateB) > 0) return stateA;
return stateB;
}
function compareStates(stateA: ObjJson, stateB: ObjJson) {
return strCompare(stateA._version, stateB._version);
}
function strCompare(str1?: string, str2?: string) {
if (str1 !== undefined && str2 !== undefined) {
if (str1 > str2) return 1;
if (str2 > str1) return -1;
}
return 0;
}