| // 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 |
| } |
| } |