@terrencecrowley/ot-js
Version:
Javascript OT library
300 lines (266 loc) • 9.24 kB
text/typescript
// Shared libraries
import * as ILog from "@terrencecrowley/logabstract";
import * as OT from "./ottypes";
import * as OTC from "./otcomposite";
import * as OTE from "./otengine";
export class OTClientEngine extends OTE.OTEngine
{
// Data members
clientID: string;
resourceID: string;
isNeedAck: boolean;
isNeedResend: boolean;
bReadOnly: boolean;
clientSequenceNo: number;
stateServer: OTC.OTCompositeResource;
stateLocal: OTC.OTCompositeResource;
valCache: any;
actionAllClient: OTC.OTCompositeResource;
actionAllPendingClient: OTC.OTCompositeResource;
actionSentClient: OTC.OTCompositeResource;
actionSentClientOriginal: OTC.OTCompositeResource;
actionServerInterposedSentClient: OTC.OTCompositeResource;
// Constructor
constructor(ilog: ILog.ILog, rid: string, cid: string)
{
super(ilog);
this.resourceID = rid;
this.clientID = cid;
this.initialize();
this.bReadOnly = false;
this.valCache = {};
}
initialize(): void
{
this.clientSequenceNo = 0;
this.isNeedAck = false;
this.isNeedResend = false;
this.actionAllClient = new OTC.OTCompositeResource(this.resourceID, this.clientID);
this.actionAllPendingClient = new OTC.OTCompositeResource(this.resourceID, this.clientID);
this.actionSentClient = new OTC.OTCompositeResource(this.resourceID, this.clientID);
this.actionSentClientOriginal = new OTC.OTCompositeResource(this.resourceID, this.clientID);
this.actionServerInterposedSentClient = new OTC.OTCompositeResource(this.resourceID, this.clientID);
this.stateServer = new OTC.OTCompositeResource(this.resourceID, this.clientID);
this.stateLocal = new OTC.OTCompositeResource(this.resourceID, this.clientID);
}
// Members
serverClock(): number
{
return this.stateServer.clock;
}
rid(): string
{
return this.resourceID;
}
cid(): string
{
return this.resourceID;
}
toValue(): any
{
return this.valCache;
}
setReadOnly(b: boolean): void
{
if (b != this.bReadOnly)
{
this.bReadOnly = b;
if (this.bReadOnly)
this.failbackToServerState();
}
}
startLocalEdit(): OTC.OTCompositeResource
{
return new OTC.OTCompositeResource(this.resourceID, this.clientID);
}
isPending(): boolean
{
return this.isNeedResend || !this.actionAllPendingClient.isEmpty();
}
getPending(): OTC.OTCompositeResource
{
if (!this.isNeedResend && this.actionAllPendingClient.isEmpty())
return null;
else
{
// If "isNeedResend" I need to send the exact same event (instead of aggregating all pending)
// because the server might have actually received and processed the event and I just didn't
// receive acknowledgement. If I merge that event into others I'll lose ability to distinguish
// that. Eventually when I re-establish communication with server I will get that event response
// and can then move on.
if (! this.isNeedResend)
{
this.actionSentClient = this.actionAllPendingClient.copy();
this.actionSentClient.clientSequenceNo = this.clientSequenceNo++;
this.actionAllPendingClient.empty();
}
this.actionSentClient.clock = this.stateServer.clock;
this.actionSentClientOriginal = this.actionSentClient.copy();
this.actionServerInterposedSentClient.empty();
this.isNeedAck = true;
this.isNeedResend = false;
return this.actionSentClient.copy();
}
}
// When I fail to send, I need to reset to resend the event again
resetPending(): void
{
if (this.isNeedAck)
{
this.isNeedAck = false;
this.isNeedResend = true;
}
}
// When I don't accurately have server state - will then refresh from server
failbackToInitialState(): void
{
this.initialize();
}
// When I have server state but my state got mixed up
failbackToServerState(): void
{
this.stateLocal = this.stateServer.copy();
this.isNeedAck = false;
this.actionSentClient.empty();
this.actionSentClientOriginal.empty();
this.actionServerInterposedSentClient.empty();
this.actionAllPendingClient.empty();
this.actionAllClient.empty();
this.valCache = this.stateLocal.toValue();
this.emit('state');
}
//
// Function: OTClientEngine.addRemote
//
// Description:
// This function is really where the action is in managing the dynamic logic of applying OT. This is run
// on each end point and handles the events received from the server. This includes server acknowledgements
// (both success and failure) of locally generated events as well as all the events generated from other
// clients.
//
// The key things that happen here are:
// 1. Track server state.
// 2. Respond to server acknowledgement of locally generated events. This also includes validation
// (with failback code) in case where server transformed my event in a way that was inconsistent
// with what I expected (due to insert collision that arose due to multiple independent events).
// 3. Transform the incoming event (by local events) so it can be applied to local state.
// 4. Transform pending local events so they can be dispatched to the service once the service
// is ready for another event.
//
addRemote(orig: OTC.OTCompositeResource): void
{
// Reset if server forces restart
if (orig.clock == OTC.clockInitialValue)
{
this.failbackToInitialState();
return;
}
// Reset if server restarted and we don't sync up
if (orig.clock < 0)
{
// If server didn't lose anything I can just keep going...
if (this.stateServer.clock+1 == -orig.clock)
orig.clock = - orig.clock
else
{
this.failbackToInitialState();
return;
}
}
// Ignore if I've seen this event already
if (orig.clock <= this.serverClock())
{
return;
}
let bMine: boolean = orig.clientID == this.clientID;
let bResend: boolean = bMine && orig.clock == OTC.clockFailureValue;
let a: OTC.OTCompositeResource = orig.copy();
if (bResend)
{
// Service failed my request. Retry with currently outstanding content.
this.resetPending();
return;
}
try
{
// Track server state and clock
this.stateServer.compose(a);
if (bMine)
{
// Validate that I didn't run into unresolvable conflict
if (! this.actionServerInterposedSentClient.isEmpty())
{
this.actionSentClientOriginal.transform(this.actionServerInterposedSentClient, true);
if (! this.actionSentClient.effectivelyEqual(this.actionSentClientOriginal))
{
this.failbackToServerState();
}
}
// I don't need to apply to local state since it has already been applied - this is just an ack.
this.isNeedAck = false;
this.actionSentClient.empty();
this.actionSentClientOriginal.empty();
this.actionServerInterposedSentClient.empty();
this.actionAllClient = this.actionAllPendingClient.copy();
}
else
{
// Transform server action to apply locally by transforming by all pending client actions
a.transform(this.actionAllClient, false);
// And then compose with local state
this.stateLocal.compose(a);
// Transform pending client by server action so it is rooted off the server state.
// This ensures that I can convert the next server action I receive.
this.actionAllClient.transform(orig, true);
// Transform server action to be after previously sent client action and then
// transform the unsent actions so they are ready to be sent.
let aServerTransformed: OTC.OTCompositeResource = orig.copy();
aServerTransformed.transform(this.actionSentClient, false);
this.actionAllPendingClient.transform(aServerTransformed, true);
// And then transform the sent client action so ready to be used for transforming next server event
this.actionSentClient.transform(orig, true);
// Track server operations interposed between a sent action
if (this.isNeedAck)
this.actionServerInterposedSentClient.compose(orig);
// Let clients know
this.valCache = this.stateLocal.toValue();
this.emit('state');
}
}
catch (err)
{
this.ilog.error("OTClientEngine.addRemote: unexpected exception: " + err);
this.failbackToInitialState();
}
}
//
// Function: addLocalEdit
//
// Description:
// This is the logic for adding an action to the local state. The logic is straight-forward
// as we need to track:
// 1. The composed set of unacknowledged locally generated events.
// 2. The composed set of unsent locally generated events (queued until sent event is acknowledged).
// 3. The local state.
// 4. An undo operation.
//
addLocalEdit(orig: OTC.OTCompositeResource): void
{
if (! this.bReadOnly)
{
try
{
this.actionAllClient.compose(orig);
this.actionAllPendingClient.compose(orig);
this.stateLocal.compose(orig);
this.valCache = this.stateLocal.toValue();
this.emit('state');
}
catch (err)
{
this.ilog.error("OTClientEngine.addLocalEdit: unexpected exception: " + err);
this.failbackToInitialState();
}
}
}
};