Where did I start?
Some slightly broken clients with installers for Mac, IOS, Android and Windows.
None of the clients worked offline, or even failed gracefully offline.
Each client, and the database supported one single string value of notes per user.
Where did I end up?
Single IOS client, capable of saving multiple notes per user, and when connected to the internet, silently syncing the changes back to the server, where it is pushed out to any connected and authenticated clients. On first loading the app/after re-authenticating, the latest server version of the notes is pushed out to the client.
Wait, but that’s less than you started with!?!
Yes… this week was a bitter sweet experience.
In return for a much improved user experience, I had to severely limit my ambitions client-wise, and focus on getting the general steps for editing, and syncing a set of notes per user between devices clear in my head.
Before I started, I knew I wanted to focus on getting one client polished and distribution ready.
I also knew that offline functionality was a priority, as was reducing the amount of data I was sending around.
Previously, I was sending the user’s entire notes string every time they made a change. This was unsubtle when it came to resolving conflicts (multiple clients logged in with simultaneous updates), whereby whichever update came in latest completely overwrote the previous one.
What a difference a week makes
First up, diffing.
I make heavy use of git at work, and so I had been thinking for a while that there must be a way of just sending round ‘diffs’ between a user’s notes, and then patching the existing notes with any incoming diffs. I hadn’t thought it through very much, but I was pretty sure I wanted to be sending round diffs, rather than the user’s entire notes.
I started by playing around locally with the ‘diff’ and ‘patch’ utilities included with unix, and so accessible via my terminal emulator.
This was pretty hopeful, and I was able to reduce a series of changes to a file, to a series of line numbers, with additions and deletions etc. So far so good.
These utils are not easily accessible in the various environments I am programming in however, and so I kept looking.
After a bunch of dead ends, I came across Google’s ‘diff match patch‘ library, which was written originally to power Google Docs, and, very kindly has been open sourced.
Google Docs kind of represents an idealised version of the kind of synchronising between clients that I am looking for, so I was pretty excited by the prospect of using the same diffing engine as they did.
After some experimentation, it seemed like this would suit my needs very well. Getting it installed on the server was very simple (npm). The package had a lot of weekly installs, the linked github had very few unresolved issues, and everything generally seemed pretty stable and reliable.
This was my initial thoughts about how this might start to look:
const { diff_match_patch } = require("diff-match-patch");
const dmp = new diff_match_patch();
let text = "Poodles can play piano";
const text2 = "Oodles can play potties\n\n\n\nwhich not a lot of people know";
const text3 = "Poodles can fully retract their eyelids";
// Both text2 and text3 clients have initial text value
let diff1 = dmp.diff_main(text, text2);
dmp.diff_cleanupSemantic(diff1);
console.log(diff1);
let diff2 = dmp.diff_main(text, text3);
dmp.diff_cleanupSemantic(diff2);
console.log(diff2);
// They are both offline so queue up the change for when they are online again,
// keeping track of the diff between their latest known server value, and their
// current value
// text2 client comes back online, and sends up its diff
const patches1 = dmp.patch_make(text, diff1);
console.log(patches1);
text = dmp.patch_apply(patches1, text)[0];
// text is updated to reflect first diff
console.log(text);
// text3 client comes back online, and sends up its diff
const patches2 = dmp.patch_make(text, diff2);
console.log(patches2);
// text is updated to reflect second diff, applied to text
text = dmp.patch_apply(patches2, text)[0];
console.log(text);
This all worked perfectly in JavaScript land.
Getting it working on the Swift (IOS) side was less pleasant however…
There were some community maintained packages for Swift, Objective C etc. which could be installed via Cocoa Pods, but they were out of date, poorly maintained and riddled with issues. I couldn’t get any of them to compile, or even install in some cases (one in particular seemed to require getting the code from a private GitHub repository, which I didn’t have access to…)
It was very frustrating, and is exactly the kind of stuff which makes me start to question whether software development is right for me.
My initial solution was to do all diffing on the server, and have the client send the previous notes, and the updated notes in each update.
This had the benefit of allowing multiple clients to simultaneously update notes and have Google’s magic diffing take care of resolving conflicts and patching together its best guess of the end results, but also meant that instead of sending all the users notes, I was sending all the users notes twice.
Not ideal.
After a bunch more research, and a lot of annoyance (this coincided with a mid 30 degree centigrade London day, which is hell), I discovered that you can run JavaScript from within Swift projects via a natively supported module called JavaScriptCore. This is my current Swift class which exposes the bits of google match patch I need:
import UIKit
import JavaScriptCore
class NotesDiffer: NSObject {
static let shared = NotesDiffer()
private let vm = JSVirtualMachine()
private let context: JSContext
override init() {
let jsCode = try? String.init(contentsOf: Bundle.main.url(forResource: "Noted.bundle", withExtension: "js")!)
self.context = JSContext(virtualMachine: self.vm)
self.context.evaluateScript(jsCode)
}
func diff(notes1: String, notes2: String) -> [Any] {
let jsModule = self.context.objectForKeyedSubscript("Noted")
let diffMatchPatch = jsModule?.objectForKeyedSubscript("diffMatchPatch")
let result = diffMatchPatch!.objectForKeyedSubscript("diff_main").call(withArguments: [notes1, notes2])
return (result!.toArray())
}
func patch(notes1: String, diff: Any) -> String {
let jsModule = self.context.objectForKeyedSubscript("Noted")
let diffMatchPatch = jsModule?.objectForKeyedSubscript("diffMatchPatch")
let patch = diffMatchPatch!.objectForKeyedSubscript("patch_make").call(withArguments: [notes1, diff])
let patched = diffMatchPatch!.objectForKeyedSubscript("patch_apply").call(withArguments: [patch, notes1])
return (patched?.toArray()[0])! as! String
}
}
I won’t go into how I did it, as this guy has a much better article, but I am using NPM and web pack to pull the same package I am using on the server, into the Swift client. Nifty stuff.
Diffing done (for now).
What do you mean you don’t have any internet!?!
After diffing, the next big issue was handling patchy network/offline mode.
Virgin Media decided to completely shit the bed at the end of the previous week, one of the results being that I was painfully confronted with how useless my app is without a reliable internet connection.
I had an idea that what would work from the client’s perspective is this:
Updating notes:
1) Update notes
2) Save
3) Am I online? If yes, push to server, if no, store the update locally
Coming back online
1) Back online, Joy
2) Do I have any pending offline changes? If yes, shoot them up to the server, otherwise do nothing
I used IOS’ ‘User Defaults’ to store the changes as a dictionary with previous notes, and updated notes, and checked it when coming back online.
It worked pretty nicely.
Unfortunately, two days into the week, I faced up to the unfortunate reality that in order for this app to be in any way useful, it needs to support multiple notes per user, which necessitated some pretty wide reaching changes.
As part of these changes, the offline functionality got removed, and hasn’t been added back yet.
Let’s get into those changes now
All of the data modelling
As mentioned, I realised I wanted/needed to support multiple notes per user.
I played around with different ideas, and settled on the idea that the underlying database would store something like this for a given user:
{
"order": ["id1", "id2", "id5", "id3"],
"details": {
"id1": {
"title": "notes 1",
"body": "first notes here"
},
"id2": {
"title": "notes 2",
"body": "second notes here"
},
"id3": {
"title": "notes 3",
"body": "third notes here"
},
"id5": {
"title": "notes 5",
"body": "fifth notes here"
}
}
}
and clients would be responsible for maintaining a local copy of the structure, in whatever format makes sense to them, and then pushing updates up to the server, so it can update its underlying model of the user’s notes, and push the changes out to all connected clients.
First problem, I had no idea how to store structured data locally to an IOS device. I had used User Defaults to store simple string data with some success, but it was a blunt instrument, and would not be suitable for storing a potentially large JSON object.
After some digging, I decided to go with what looked like the most IOS-ey, Apple recommended approach, and use the CoreData framework:
“Core Data is an object graph and persistence framework provided by Apple in the macOS and iOS operating systems”
Which seemed good because, hopefully it will be well documented, and widely used, and also, I might be able to reuse the models in any upcoming macOS client work.
It is an abstraction over some sort of persistent device storage. I don’t actually know what form the data is saved in, whether it uses SQLite or not, and I don’t really care at the moment. The main benefit from my perspective is that I hoped I would be able to define some sort of model, corresponding to the JSON data structure above, and keep it in sync with the server.
This has been largely successful, but was quite painful to get started.
I didn’t find CoreData particularly intuitive, possibly because my professional interactions with data persistence has been limited largely to Redux stores and Cookies/local storage etc. on the front end.
What I ended up with was this CoreData model:
Which doesn’t look too impressive!
I then added a bunch of static methods to the generated Note class, (which is an instance of an NSManagedObject, provided by the CoreData framework, meaning it can get persisted and stuff). These methods were to support custom read/write operations that I needed for my application. Currently they look like this:
extension Note {
public static func noteById(id: String, in context: NSManagedObjectContext) -> Note? {
let serverNotesFetch = NSFetchRequest<NSFetchRequestResult>(entityName: "Note")
serverNotesFetch.predicate = NSPredicate(format: "id = %@", id)
do {
let fetchedNotes = try context.fetch(serverNotesFetch) as! [Note]
print(fetchedNotes)
if(fetchedNotes.count > 0) {
print("found a note")
return fetchedNotes[0]
} else {
print("no note found")
return nil
}
} catch {
fatalError("Failed to fetch note by id: \(error)")
}
}
static func create(in managedObjectContext: NSManagedObjectContext, noteId: String? = nil, title: String? = nil, body: String? = nil){
let newNote = self.init(context: managedObjectContext)
newNote.id = noteId ?? UUID().uuidString
newNote.title = title ?? ""
newNote.body = body ?? ""
do {
try managedObjectContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
static func updateTitle(note: Note, title: String, in managedObjectContext: NSManagedObjectContext) {
note.title = title
do {
try managedObjectContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
static func updateBody(note: Note, body: String, in managedObjectContext: NSManagedObjectContext) {
note.body = body
do {
try managedObjectContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
public static func deleteAllNotes(in managedObjectContext: NSManagedObjectContext) {
// Create Fetch Request
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Note")
// Create Batch Delete Request
let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
do {
try managedObjectContext.execute(batchDeleteRequest)
} catch {
// Error Handling
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
public static func deleteAllNotesApartFrom(ids: [String], in managedObjectContext: NSManagedObjectContext) {
print("deleting all notes apart from \(ids)")
let notesFetch = NSFetchRequest<NSFetchRequestResult>(entityName: "Note")
notesFetch.predicate = NSPredicate(format: "NOT id IN %@", ids)
do {
let fetchedNotes = try managedObjectContext.fetch(notesFetch) as! [Note]
fetchedNotes.forEach { note in
managedObjectContext.delete(note)
}
try managedObjectContext.save()
} catch {
fatalError("Failed to fetch note by id: \(error)")
}
}
public static func deleteNote(note: Note, in managedObjectContext: NSManagedObjectContext) {
managedObjectContext.delete(note)
do {
try managedObjectContext.save()
} catch {
// Error Handling
let nserror = error as NSError
fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
}
}
}
extension Collection where Element == Note, Index == Int {
func delete(at indices: IndexSet) {
indices.forEach {
NotesService.shared.deleteNote(id: self[$0].id!)
}
}
}
CoreData provides a query language via NSPredicate
objects, to filter collections.
In I maintain a local collection of Note objects, which I can make changes to, and save to the device at key points.
Data flow
At this point, things started to click a bit, and to feel very familiar. I refactored my big monolith SwiftUI view, into a bunch of littler views, managed the application flow, and state, from the main ContentView
, and registered callbacks with services responsible for Auth and socket connections etc. as well as with child views, which then once they were ready, sent events letting the ContentView
, know that they had an update, at which point it sent the update to the relevant place.
Because I haven’t tackled offline functionality yet, currently there is a one way data flow, where the client sends update actions up to the server, which updates the database, and if successful, sends the updates out to all connected clients, which then update their local copy of the notes with the changes.
It works really nicely!
The ever changing notes protocol
Because I am now supporting multiple notes, and I have the ability to apply diffs on both the client and the server, my protocol for communicating notes updates changes somewhat.
I have ended up with the following events:
"updateNote", {
"id": "noteId1",
"title: "a diff match patch diff",
"body: "a diff match patch diff"
}
"noteUpdated", {
"id": "noteId1",
"title: "a diff match patch diff",
"body: "a diff match patch diff"
}
"deleteNote", "noteId1"
"noteDeleted", "noteId1"
"initialNotes",
"{
"details": {
"id1": {
"title": "notes 1",
"body": "first notes here"
},
"id2": {
"title": "notes 2",
"body": "second notes here"
},
"id3": {
"title": "notes 3",
"body": "third notes here"
},
"id5": {
"title": "notes 5",
"body": "fifth notes here"
}
}"
I think I probably also want a “createNote” and “noteCreated” action for increased clarity, but even as things stand, these actions have allowed me to keep the server and client(s) in sync very nicely, assuming there is an internet connection.
What next?
Same as every week… App stores! I really want to actually get a beta/testing app that I can send out to people by the end of this week.
Offline mode.
Client for desktop.
Actually test my code… figure out how to unit/ui test SwiftUI projects.
Last week was exhausting, I imagine this week will be the same.