blob: 11c3c366550ac2f721cc873d8a7c8d34eb955e0e [file] [log] [blame]
// Copyright 2016 The Vanadium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// The Todo app's model uses the Syncbase key-value store using the following data types:
//
// type TodoList struct {
// Name string
// UpdatedAt timestamp
// }
//
// type Task struct {
// Text string
// AddedAt timestamp
// Done bool
// }
//
// These data types are currently serialized using JSON until VOM has been ported to Swift, at which
// point we'll use the generated VDL / VOM structs. Until then, we do not have cross-platform
// compatibility with Android, which uses VOM.
//
// Each Todo List is stored in its own collection in the Syncbase database, where its rows represent
// the individual tasks. The collection is named with a unique UUID (that UUID is generated
// automatically by the API's Database.createCollection method). The metadata on this list, that is
// to say the serialized TodoList struct, is stored in a special row in these collections with
// the key `todoListKey`. All other rows are the serialized `Task` structs.
//
// This file provides the CRUD (create, read, update, delete) APIs to manipulate todo lists and
// structs in their high-level Swift representations. It also contains the logic that "watches" the
// Syncbase database in order to replicate these lists and todos in-memory as Swift structs as
// the data changes (either from the user invoking the CRUD APIs or from other users). As the watch
// logic determines the exact change, the related `ModelEvent` notifies any listeners to these
// changes, which are the TodosViewController or the TasksViewController.
//
// Note that the data model is "unidirectional," which is to say that when the user press a button,
// that button only will call the appropriate data model API call. It does not update the UI itself.
// Instead, the running watch will receive the change the same way regardless if it originated on
// this device or another, and update the UI accordingly. This is the principal intended design of
// Syncbase.
import Foundation
import Syncbase
let todoListKey = "list"
let todoListCollectionPrefix = "lists"
let userProfilePhotoURLKey = "userProfilePhotoURL"
var todoLists: [TodoList] = []
// MARK: Watch high level events
enum ModelEvent {
case ReloadLists(lists: [TodoList])
case AddList(list: TodoList, index: Int)
case UpdateList(list: TodoList, index: Int)
case DeleteList(list: TodoList, index: Int)
case AddTask(list: TodoList, listIndex: Int, task: Task, taskIndex: Int)
case UpdateTask(list: TodoList, listIndex: Int, task: Task, taskIndex: Int)
case DeleteTask(list: TodoList, listIndex: Int, task: Task, taskIndex: Int)
}
// Struct to hold the callbacks for WatchEvents. It is hashable to support adding/removing callbacks
// which otherwise cannot be equated.
private let dispatch = Dispatch<ModelEvent>()
final class ModelEventHandler: DispatchHandler<ModelEvent> {
required init(onEvents: [ModelEvent] -> ()) {
super.init(onEvents: onEvents)
}
}
/// Used by the ViewControllers to subscribe to model events.
func startWatchModelEvents(eventHandler: ModelEventHandler) {
dispatch.watch(eventHandler)
}
/// Used by the ViewControllers to unsubscribe to model events.
func stopWatchingModelEvents(eventHandler: ModelEventHandler) {
dispatch.unwatch(eventHandler)
}
// MARK: Invites
private var inviteHandler: SyncgroupInviteHandler?
func startInviteHandler() throws {
if let handler = inviteHandler {
try Syncbase.database().removeSyncgroupInviteHandler(handler)
inviteHandler = nil
}
let handler = SyncgroupInviteHandler(
onInvite: { (invite: SyncgroupInvite) in
// Accept all invites automatically.
print("Accepting invite \(invite)")
try! Syncbase.database().acceptSyncgroupInvite(invite, callback: { (sg, err) in
if let err = err {
print("Unable to accept invite: \(err)")
} else {
print("Accepted invite into syncgroup \(sg)")
}
})
},
onError: { (err: ErrorType) in
print("Unable to accept invites: \(err)")
})
try Syncbase.database().addSyncgroupInviteHandler(handler)
inviteHandler = handler
}
// MARK: Watch low-level events
private var watchHandler: WatchChangeHandler?
func startWatching() throws {
if let handler = watchHandler {
try Syncbase.database().removeWatchChangeHandler(handler)
watchHandler = nil
}
let handler = WatchChangeHandler(
onInitialState: onInitialState,
onChangeBatch: onChangeBatch,
onError: { err in
// No more calls to watch will occur.
print("Unexpected error watching model: \(err)")
})
try Syncbase.database().addWatchChangeHandler(handler: handler)
watchHandler = handler
}
private func onInitialState(changes: [WatchChange]) {
// As the changes can come in any order (e.g. a task after the TodoList serialized), we bucket
// by collectionId and keep the serialized todoList row first. That way we can re-construct all
// of the data properly.
// Now that we have it all bucketed and properly ordered, reconstruct the data.
todoLists.removeAll()
for (collectionId, changes) in groupListChangesByCollectionId(changes) {
// TOD(zinman): How can this fail? How to better handle than crashing?
let collection = try! Syncbase.database().collection(collectionId)
// groupListChangesByCollectionId puts the serialized TodoList first.
guard let list = changes.first?.toTodoList(collection) else {
continue
}
for change in changes.dropFirst() {
if let task = change.toTask() {
list.tasks.append(task)
}
}
todoLists.append(list)
}
dispatch.notify([ModelEvent.ReloadLists(lists: todoLists)])
}
func onChangeBatch(changes: [WatchChange]) {
var events: [ModelEvent] = []
for (collectionId, var changes) in groupListChangesByCollectionId(changes) {
// TOD(zinman): How can this fail? How to better handle than crashing?
let collection = try! Syncbase.database().collection(collectionId)
var list: TodoList!
// This is a O(N) search, but it's ok since this is after the initial state we expeect few of
// these callbacks AND n to be small. If either of these assumptions change we'll need to
// use a map or an alternative approach to loading all into memory.
var listIdx = todoLists.indexOf({ $0.collection?.collectionId == collectionId })
if let idx = listIdx {
list = todoLists[idx]
}
// The first change will always be a TodoList (row == todoListKey) IF this batch is adding
// updating, or deleting a TodoList. Otherwise, the changes are to tasks in the TodoList.
let firstChange = changes.first!
if let newList = firstChange.toTodoList(collection) {
changes = Array(changes.dropFirst(1))
// First change is a Put that contains a TodoList. It's either an insert or an update.
if listIdx != nil {
// It's an update of the TodoList. We can recycle its tasks knowing any updates to its
// tasks will be in a separate change.
newList.tasks = list.tasks
// TODO(zinman): Confirm this later on.
newList.members = list.members
list = newList
todoLists[listIdx!] = list
events.append(ModelEvent.UpdateList(list: list, index: listIdx!))
} else {
// Inserting a new TodoList.
list = newList
listIdx = todoLists.count
todoLists.append(list)
events.append(ModelEvent.AddList(list: list, index: listIdx!))
}
} else if firstChange.changeType == .Delete &&
(firstChange.row == todoListKey || firstChange.entityType == .Collection) {
changes = Array(changes.dropFirst(1))
// Deleting this collection -- since it's already grouped by this collectionId we can
// continue after deleting the TodoList.
if listIdx != nil {
todoLists.removeAtIndex(listIdx!)
events.append(ModelEvent.DeleteList(list: list, index: listIdx!))
}
continue
} else if listIdx == nil {
// Don't have list existing or incoming to process the changes.
continue
}
// Process task changes
for change in changes {
if let task = change.toTask() {
// Is it an update or an insert?
if let taskIdx = list.tasks.indexOf({ $0.key == task.key }) {
list.tasks[taskIdx] = task
events.append(ModelEvent.UpdateTask(list: list, listIndex: listIdx!, task: task, taskIndex: taskIdx))
} else {
list.tasks.append(task)
events.append(ModelEvent.UpdateTask(list: list, listIndex: listIdx!, task: task, taskIndex: list.tasks.count - 1))
}
} else if change.changeType == .Delete,
let row = change.row,
key = NSUUID(UUIDString: row) {
if let taskIdx = list.tasks.indexOf({ $0.key == key }) {
let task = list.tasks.removeAtIndex(taskIdx)
events.append(ModelEvent.DeleteTask(list: list, listIndex: listIdx!, task: task, taskIndex: taskIdx))
}
}
}
}
dispatch.notify(events)
}
// MARK: Model API
func addList(list: TodoList) throws {
let data = try NSJSONSerialization.dataWithJSONObject(list.toJsonable(), options: [])
list.collection = try Syncbase.database().createCollection(prefix: todoListCollectionPrefix)
try list.collection!.put(todoListKey, value: data)
// No need to update the local data ourselves -- that will happen in the watch handler.
}
func removeList(list: TodoList) throws {
guard let collection = list.collection else {
throw SyncbaseError.IllegalArgument(detail: "Missing collection from TodoList: \(list)")
}
try collection.destroy()
// No need to update the local data ourselves -- that will happen in the watch handler.
}
func addTask(list: TodoList, task: Task) throws {
let data = try NSJSONSerialization.dataWithJSONObject(task.toJsonable(), options: [])
let key = task.key.UUIDString
try list.collection!.put(key, value: data)
}
func removeTask(list: TodoList, task: Task) throws {
let key = task.key.UUIDString
try list.collection!.delete(key)
}
func setTaskIsDone(list: TodoList, task: Task, isDone: Bool) throws {
task.done = isDone
// This is the same operation as updating the row since we put the data.
try addTask(list, task: task)
}
func setTasksAreDone(list: TodoList, tasks: [Task], isDone: Bool) throws {
try Syncbase.database().runInBatch { bdb in
for task in tasks {
task.done = isDone
let data = try NSJSONSerialization.dataWithJSONObject(task.toJsonable(), options: [])
let key = task.key.UUIDString
// We must get a reference via the batch database handle rather than the existing non-batch
// cached collection.
try bdb.collection(list.collection!.collectionId).put(key, value: data)
}
}
}
func setUserPhotoURL(url: NSURL) throws {
let jsonable = [url.absoluteString]
let json = try NSJSONSerialization.dataWithJSONObject(jsonable, options: [])
try Syncbase.database().userdataCollection.put(userProfilePhotoURLKey, value: json)
}
func userProfileURL() throws -> NSURL? {
let data: NSData? = try Syncbase.database().userdataCollection.get(userProfilePhotoURLKey)
guard let json = data,
jsonable = try NSJSONSerialization.JSONObjectWithData(json, options: []) as? [String],
url = jsonable.first else {
return nil
}
return NSURL(string: url)
}
// MARK: Helpers
private func groupListChangesByCollectionId(changes: [WatchChange]) -> [Identifier: [WatchChange]] {
var changesByCollectionId: [Identifier: [WatchChange]] = [:]
for change in changes {
guard let collectionId = change.collectionId where collectionId.name.hasPrefix(todoListCollectionPrefix) else {
continue
}
// Keep deletes on all entities (collections for lists, rows for tasks), but otherwise only keep
// row changes for puts -- we don't use the collection put change.
if change.changeType == .Put && change.entityType != .Row {
continue
}
if var cxChanges = changesByCollectionId[collectionId] {
if change.row == todoListKey {
// This is the row that describes the whole TodoList. Keep it first.
cxChanges.insert(change, atIndex: 0)
} else {
cxChanges.append(change)
}
changesByCollectionId[collectionId] = cxChanges
} else {
changesByCollectionId[collectionId] = [change]
}
}
return changesByCollectionId
}
private extension WatchChange {
func toTask() -> Task? {
if changeType != .Put || entityType != .Row || row == todoListKey {
return nil
}
guard let row = row,
key = NSUUID(UUIDString: row),
json = value,
obj = try? NSJSONSerialization.JSONObjectWithData(json, options: []) as? [String: AnyObject],
jsonable = obj,
task = Task.fromJsonable(jsonable) else {
return nil
}
task.key = key
return task
}
func toTodoList(collection: Collection) -> TodoList? {
if changeType != .Put || entityType != .Row || row != todoListKey {
return nil
}
guard let json = value,
obj = try? NSJSONSerialization.JSONObjectWithData(json, options: []) as? [String: AnyObject],
jsonable = obj,
list = TodoList.fromJsonable(jsonable) else {
return nil
}
list.collection = collection
return list
}
}