Skip to content
This repository has been archived by the owner on Jan 16, 2023. It is now read-only.

Commit

Permalink
Implemented basic Server and Client operational transformation handlers
Browse files Browse the repository at this point in the history
  • Loading branch information
eitak committed Mar 3, 2016
0 parents commit 23fc530
Show file tree
Hide file tree
Showing 8 changed files with 332 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"presets": ["es2015"],
"plugins": ["syntax-async-functions","transform-regenerator"]
}
15 changes: 15 additions & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"extends": "eslint:recommended",
"ecmaFeatures": {
"arrowFunctions": true,
"blockBindings": true,
"classes": true,
"defaultParams": true,
"generators": true,
"modules": true,
"templateStrings": true
},
"rules": {
"no-sequences": 1
}
}
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.idea
node_modules
75 changes: 75 additions & 0 deletions client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import uuid from 'node-uuid'

export class ClientActionManager {

constructor(sendActionToServer, sendActionToClient, mergeActions, lastSequenceNumber, clientId, stateId) {
this.sendActionToServer = sendActionToServer;
this.sendActionToClient = sendActionToClient;
this.clientActionsToSave = [];
this.mergeActions = mergeActions;
this.lastSequenceNumber = lastSequenceNumber;
this.clientId = clientId;
}

applyClientAction(action) {
const actionToSave = {
action: action,
clientId: this.clientId
};

this.clientActionsToSave.push(actionToSave);

if (this.clientActionsToSave.length === 1) {
this._sendNextActionToServer();
}

console.log('Sending action to client: %j', action);
this.sendActionToClient(action);
}

_sendNextActionToServer() {
if (this.clientActionsToSave.length > 0) {
var actionToSave = Object.assign({}, this.clientActionsToSave[0], {
sequenceNumber: this.lastSequenceNumber + 1,
stateId: this.stateId
});
console.log('Sending action to server: %j', actionToSave);
this.sendActionToServer(actionToSave);
}
}

applyServerAction(action) {
const expectedSequenceNumber = this.lastSequenceNumber + 1;
var invalidSequenceNumber = action.sequenceNumber !== expectedSequenceNumber;
if (invalidSequenceNumber) {
console.error('Received action with invalid sequence number (expected %d): %j', expectedSequenceNumber, action);
return;
}

this.lastSequenceNumber = action.sequenceNumber;

const isKnownAction = this.clientId === action.clientId;
if (isKnownAction) {
console.log('Received confirmation that our action saved: %j', action.action);
this.clientActionsToSave.shift();
this._sendNextActionToServer();
return;
}

console.log('Received new action from the server: %j', action);

const transformedActions = this.clientActionsToSave
.reduce((transformedActions, nextAction) => {
const actionToMergeWith = transformedActions.serverAction;
const mergedActions = this.mergeActions(nextAction.action, actionToMergeWith.action);
transformedActions.clientActions.push(Object.assign({}, actionToMergeWith, {action: mergedActions[1]}));
transformedActions.serverAction = Object.assign({}, nextAction, {action: mergedActions[0]});
return transformedActions;
}, {clientActions: [], serverAction: action});

const transformedServerAction = transformedActions.serverAction;
this.sendActionToClient(transformedServerAction.action);
this.clientActionsToSave = transformedActions.clientActions;
}

}
27 changes: 27 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "redux-ot",
"version": "0.0.1",
"description": "",
"main": "index.js",
"directories": {
"test": "test"
},
"scripts": {
"test": "mocha --compilers js:babel-register --reporter nyan",
"compile": "babel -d lib/ src/",
"prepublish": "npm run compile"
},
"author": "",
"license": "ISC",
"devDependencies": {
"babel-cli": "^6.4.5",
"babel-plugin-syntax-async-functions": "^6.3.13",
"babel-preset-es2015": "^6.3.13",
"babel-register": "^6.4.3",
"should": "^8.1.1"
},
"dependencies": {
"lodash": "^4.6.1",
"node-uuid": "^1.4.7"
}
}
37 changes: 37 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import _ from 'lodash'

export class ServerActionManager {

constructor(getActionBySequenceNumber, getLastSequenceNumber, saveAction, mergeActions) {
this.getActionBySequenceNumber = getActionBySequenceNumber;
this.getLastSequenceNumber = getLastSequenceNumber;
this.saveAction = saveAction;
this.mergeActions = mergeActions;
}

applyClientAction(action) {
const lastSequenceNumber = this.getLastSequenceNumber(action.stateId);
const nextSequenceNumber = lastSequenceNumber + 1;

if (action.sequenceNumber > nextSequenceNumber) {
throw new Error('Invalid sequence number');
}

const serverActions = _.range(action.sequenceNumber, nextSequenceNumber)
.map((sequenceNumber) => this.getActionBySequenceNumber(sequenceNumber).action);

const actionToSave = serverActions
.reduce((transformedAction, serverAction) => {
const mergedActions = this.mergeActions(transformedAction, serverAction);
return mergedActions[1];
}, action.action);

this.saveAction({
sequenceNumber: nextSequenceNumber,
clientId: action.clientId,
stateId: action.stateId,
action: actionToSave
});
}

}
103 changes: 103 additions & 0 deletions test/client.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {ClientActionManager} from '../client'

require('should');

describe('ClientActionManager', () => {

var underTest, actionsSentToServer, actionsSentToClient;

const action1 = 'action1';
const action2 = 'action2';
const action3 = 'action3';
const action4 = 'action4';
const action5 = 'action5';

const clientId = 'test-client';

beforeEach(() => {
actionsSentToClient = [];
actionsSentToServer =[];
underTest = new ClientActionManager(
(action) => actionsSentToServer.push(action),
(action) => actionsSentToClient.push(action),
(a1, a2) => [`(${a1}-${a2})`, `(${a1}x${a2})`],
1, clientId);
});

it('should apply client actions and send request to server', () => {
underTest.applyClientAction(action1);

actionsSentToClient.should.have.length(1);
actionsSentToClient[0].should.eql(action1);

actionsSentToServer.should.have.length(1);
actionsSentToServer[0].action.should.eql(action1);
actionsSentToServer[0].sequenceNumber.should.eql(2);
});

it('should apply actions received from the server', () => {
underTest.applyServerAction({ action: action1, sequenceNumber: 2 });

actionsSentToClient.should.have.length(1);
actionsSentToClient[0].should.eql(action1);

actionsSentToServer.should.be.empty();
});

it('should set the sequence number as the last server sequence number + 1', () => {
underTest.applyServerAction({ action: action1, sequenceNumber: 2 });

underTest.applyClientAction(action2);

actionsSentToClient.should.have.length(2);
actionsSentToClient[0].should.eql(action1);
actionsSentToClient[1].should.eql(action2);

actionsSentToServer.should.have.length(1);
actionsSentToServer[0].action.should.eql(action2);
actionsSentToServer[0].sequenceNumber.should.eql(3);
});

it('should ignore server actions which were initiated by this client', () => {
underTest.applyServerAction({ action: action1, sequenceNumber: 2, clientId: clientId });

actionsSentToServer.should.have.length(0);
actionsSentToClient.should.have.length(0);
});

it('should ignore server actions which have the same sequence number as the last one', () => {
underTest.applyServerAction({ action: action1, sequenceNumber: 1, clientId: clientId });

actionsSentToServer.should.have.length(0);
actionsSentToClient.should.have.length(0);
});

it('should apply transformed action to client', () => {
underTest.applyClientAction(action1);
underTest.applyServerAction({ action: action2, sequenceNumber: 2 });

actionsSentToServer.should.have.length(1);
actionsSentToServer[0].action.should.eql(action1);
actionsSentToServer[0].sequenceNumber.should.eql(2);

actionsSentToClient.should.have.length(2);
actionsSentToClient[0].should.eql(action1);
actionsSentToClient[1].should.eql('(action1-action2)');
});

it('should handle a more complex transformation', () => {
underTest.applyClientAction(action1);
underTest.applyClientAction(action2);
underTest.applyServerAction({ sequenceNumber: 2, action: action3 });
underTest.applyClientAction(action5);
underTest.applyServerAction({ sequenceNumber: 3, action: action4 });

actionsSentToClient.should.have.length(5);
actionsSentToClient[0].should.eql(action1);
actionsSentToClient[1].should.eql(action2);
actionsSentToClient[2].should.eql('(action2-(action1-action3))');
actionsSentToClient[3].should.eql(action5);
actionsSentToClient[4].should.eql('(action5-((action2x(action1-action3))-((action1xaction3)-action4)))');
})

});
69 changes: 69 additions & 0 deletions test/server.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {ServerActionManager} from '../server'

describe('ServerActionManager', () => {

var underTest, actionsSaved;

const actions = [
{ stateId: 'test-state-id', sequenceNumber: 1, action: 'action1' },
{ stateId: 'test-state-id', sequenceNumber: 2, action: 'action2', parent: 'action1-id' },
{ stateId: 'test-state-id', sequenceNumber: 3, action: 'action3', parent: 'action2-id' }
];
const clientId = 'test-client';

beforeEach(() => {
actionsSaved = [];
underTest = new ServerActionManager(
(sequenceNumber) => actions[sequenceNumber - 1],
() => 3,
(action) => actionsSaved.push(action),
(a1, a2) => [`(${a1}-${a2})`, `(${a1}x${a2})`]);
});

it('should reject actions with parent not saved on the server', () => {
(() => underTest.applyClientAction({
stateId: 'test-state-id',
sequenceNumber: 5,
action: 'action4',
clientId: clientId
})).should.throw();

actionsSaved.should.be.empty();
});

it('should save actions where the parent is the last saved action', () => {
var actionToSave = {
stateId: 'test-state-id',
sequenceNumber: 4,
action: 'action4',
clientId: clientId
};
underTest.applyClientAction(actionToSave);

actionsSaved.should.have.length(1);
actionsSaved[0].should.eql(actionToSave);
});

it('should save actions where the parent is not the last saved action', () => {
var actionToSave = {
stateId: 'test-state-id',
sequenceNumber: 2,
action: 'action4',
clientId: clientId
};
underTest.applyClientAction(actionToSave);

actionsSaved.should.have.length(1);
actionsSaved[0].should.eql({
stateId: 'test-state-id',
sequenceNumber: 4,
action: '((action4xaction2)xaction3)',
clientId: clientId
});
});

it('should retry saving actions when the state changes in between retrieving actions and attempting save', () => {

});

});

0 comments on commit 23fc530

Please sign in to comment.