Introducing Code-Replay, and how we built it

Logicboard.com
Firebase Developers
4 min readJan 13, 2021

Logicboard is a collaborative code editor for conducting technical interviews. We believe the most important aspect of a technical interview is to understand a candidate’s thought process. Having a working solution to a problem during an interview would be ideal, but there’s never really a single solution to any problem.

At the end of an interview, what I usually take away is some code that a candidate wrote and a few key points that I remember. But it’s hard to convey a candidate’s thought process to the rest of the team based on a few lines of code.

But what if I could replay the entire coding session to the rest of the team? We thought that would give everyone a better context and help understand the thought process behind a solution. This is in fact one of the top feedback we got from our customers.

Today we’re rolling out Code-Replay, a feature that lets you replay the entire interview session line-by-line. It’s like having the ability to undo/redo even after the interview has ended.

Here’s Code-Replay in action:

You can also check out the live demo here

Let’s take look at how we built this

Logicboard uses Firebase+Firepad to power the code editor. Firepad uses Firebase Realtime Database to keep a versioned history of a document.

Firebase

Let’s deep dive in to how this history is stored in Firebase. Here’s what gets stored in Firebase when I type The

  • A0, A1 and A2 are the revisions of document created each time I edit content.
  • User ID is the ID of the user that created this revision. This is helpful when multiple users are collaborating.
  • o is for operation, it contains the actual information about changes made in this version.
  • Index is the position of the text added/deleted in the document.
  • Content is the actual edited content. If it is an insert, it contains the inserted text and in case of deletion this will be a negative integer indicating the number of characters deleted.
  • Timestamp is the timestamp for this revision.

This structure is sequential and each revision stores just the delta. To restore a specific revision all you need to do is apply changes from A0 through An. This is conceptually similar to git, you can think of each revision as a git commit.

TextOperation

Each revision in Firebase is represented by a TextOperation in Firepad.

You can create a TextOperation from operation (o) of a Firebase revision:

const operation = Firepad.TextOperation.fromJSON(history.A0.o)

TextOperation provides a handy compose function that combines the changes of two operations in to a new operation. This new operation can be further composed with another operation, so on and so forth.

// "T"
const a0 = Firepad.TextOperation.fromJSON(history.A0.o)
// "Th"
const a1 = a0.compose(Firepad.TextOperation.fromJSON(history.A1.o))
// "The"
const final = a1.compose(Firepad.TextOperation.fromJSON(history.A2.o))

You can think of compose like cherry-pick in git.

Putting it together

If we start with the operation for first revision and recursively compose subsequent operations, we’ll end up with an operation that contains the final state of the document. So to restore a specific version An just compose operations A0…An.

With this in mind we can break down the entire process in to:

1. Fetching revisions from Firebase

Fetching history from firebase is pretty straightforward:

const fetchRevisions = async (firebaseRef, callback) => {
firebaseRef.child('history').once("value", function (snapshot) {
const revisions = snapshot.val()
}

2. Wrap each revision in to a TextOperation

Once we have revisions from Firebase, we create a TextOperation for each revision.

Also to make it easy to restore revision for a given timestamp, we generate a dictionary of {timestamp : TextOperation}:

const fetchRevisions = async (firebaseRef, callback) => {
firebaseRef.child('history').once("value", function (snapshot) {
const revisions = snapshot.val()
var revisionsByTimestamp = {}
const Firepad = require('firepad')
for (key in revisions) {
const revision = revisions[key]
const operation = Firepad.TextOperation.fromJSON(revision.o)
revisionsByTimestamp[`${revision.t}`] = operation
}

callback(revisionsByTimestamp)
})
}

3. Recursively compose and get final text

If we recursively compose all the TextOperations from A0 to An where An is the specified revision, we’ll end up with a TextOperation that combines all changes to the document up until An:

  for (var key of keys) {
const operation = revisions[key]
document = document.compose(operation)
if (key === revision) {
break
}
}

Next, we need the actual text content of the finalTextOperation, to do this we just use TextOperation’s toJSON function. This function returns an array whose first element is the resulting text:

document.ops.length ? document.toJSON().slice(-1).pop() : null

TL;DR

const fetchRevisions = async (firebaseRef, callback) => {firebaseRef.child('history').once("value", function (snapshot) {
const revisions = snapshot.val()
// Map revisions by their timestamp to make it easy to restore
var revisionsByTimestamp = {}
const Firepad = require('firepad')
for (key in revisions) {
const revision = revisions[key]
const operation = Firepad.TextOperation.fromJSON(revision.o)
revisionsByTimestamp[`${revision.t}`] = operation
}
callback(revisionsByTimestamp)
})
}
const textForRevision = (revision, revisions) => {
const Firepad = require('firepad')
var document = new Firepad.TextOperation()
const keys = Object.keys(revisions).sort()
for (var key of keys) {
const operation = revisions[key]
document = document.compose(operation)
if (key === revision) {
break
}
}
return document.ops.length ? document.toJSON().slice(-1).pop() : null
}
module.exports = {
fetchRevisions,
textForRevision,
}

Usage:

const Firepad = require('firepad')
const Firebase = require('firebase')
const firebaseRef = Firebase.database().ref().child('<child location>')
firepad.on('ready', function () {
fetchRevisions(firebaseRef, revisionInfo => {
const timestamp = // eg: "1601406184335"
const text = textForRevision(timestamp, revisionInfo)
codeMirror.setValue(text)
})
})

Final thoughts

We think our Firebase-based replay implementation proved to be a faster and much cleaner solution. It is super lightweight, a typical hour-long coding session takes up only a few hundred kb.

To experience all of this in action, check out our live demo.

--

--