Skip to content

Instantly share code, notes, and snippets.

@emorydunn
Last active July 28, 2023 19:37
Show Gist options
  • Save emorydunn/193b6c66583650e727fac6277036cdc7 to your computer and use it in GitHub Desktop.
Save emorydunn/193b6c66583650e727fac6277036cdc7 to your computer and use it in GitHub Desktop.
Coordinated Store
//
// Dict+ID.swift
//
//
// Created by Emory Dunn on 7/25/23.
//
import Foundation
import Collections
extension OrderedDictionary {
/// Creates a new dictionary from the identifiable values in the given sequence.
///
/// You use this initializer to create a dictionary when you have a sequence
/// of identifiable values. Passing a sequence with duplicate
/// keys to this initializer results in a runtime error.
///
/// The ID of the values will be used as the key in the resulting dictionary.
///
/// - Parameter identifiedArray: A sequence of value.
///
/// - Returns: A new dictionary initialized with the elements of
/// `identifiedArray`.
///
/// - Precondition: The sequence must not have duplicate keys.
///
/// - Complexity: Expected O(*n*) on average, where *n* is the count if
/// key-value pairs, if `Key` implements high-quality hashing.
@inlinable
public init<S: Sequence>(identifiedArray: S) where S.Element: Identifiable, S.Element.ID: Hashable {
// Add tuple labels
let keysAndValues = identifiedArray.map { ($0.id as! Key, $0 as! Value) }
self.init(uniqueKeysWithValues: keysAndValues)
}
}
//
// StorageCoordinator.swift
//
//
// Created by Emory Dunn on 7/25/23.
//
import Foundation
import OSLog
fileprivate var log = Logger(subsystem: "FSIDB", category: "StorageCoordinator")
/// An object that coordinates reading and writing a presented file.
///
/// The `StorageCoordinator` has methods for asynchronously reading from and writing to a file
/// in a coordinated manor.
final class StorageCoordinator: NSObject, NSFilePresenter {
/// The URL of the presented file or directory.
let presentedItemURL: URL?
/// he operation queue in which to execute presenter-related messages.
let presentedItemOperationQueue: OperationQueue
/// The coordinator used for file access.
var coordinator: NSFileCoordinator!
/// The last time the file was read.
private(set) var lastReadDate: Date = Date.distantPast
/// The last time the file was written to.
private(set) var lastWriteDate: Date = Date.distantFuture
/// A callback to pass `presentedItemDidChange()` to an observer.
var itemDidChange: (() async throws -> Void)?
init(url presentedItemURL: URL?, queue presentedItemOperationQueue: OperationQueue = .main) {
// Store properties
self.presentedItemURL = presentedItemURL
self.presentedItemOperationQueue = presentedItemOperationQueue
// Super
super.init()
// Register the presenter
NSFileCoordinator.addFilePresenter(self)
self.coordinator = NSFileCoordinator(filePresenter: self)
}
// func savePresentedItemChanges() async throws {
// print("Asked to save changes")
//
// try writeFile()
// }
/// Tells your object that the presented item’s contents or attributes changed.
///
/// This method checks if the file needs reading and calls `itemDidChange()` if it does.
func presentedItemDidChange() {
log.debug("presentedItemDidChange")
// If the file needs reading call the handler
if fileNeedsReading() {
Task {
log.debug("Calling itemDidChange callback")
try await itemDidChange?()
}
}
}
/// Determine if the file needs to be read based on the modification date.
/// - Returns: A Boolean indicating whether the file needs to be read
func fileNeedsReading() -> Bool {
guard let lastModDate = lastModifiedDate() else {
return false
}
return lastModDate > lastReadDate
}
/// Read the content modification date of the presented URL.
/// - Returns: The Date the content was modified, or nil if it couldn't be read.
func lastModifiedDate() -> Date? {
guard let presentedItemURL else { return nil }
let values = try? presentedItemURL.resourceValues(forKeys: [.contentModificationDateKey])
return values?.contentModificationDate
}
/// Read data from the presented URL.
///
/// This method coordinates to asynchronously reads the contents of the file.
/// - Returns: The contents of the fie.
func readData() async throws -> Data {
// We need a file to read from
guard let presentedItemURL else { throw StorageError.noURL }
return try await withCheckedThrowingContinuation { continuation in
var error: NSError? // A read error
// Coordinate reading from the file
coordinator.coordinate(readingItemAt: presentedItemURL, error: &error) { url in
// Do a sanity check on whether the file exists
// Technically we should just attempt the read
// and catch the error, but that's tricky
guard FileManager.default.fileExists(atPath: url.path) else {
continuation.resume(throwing: StorageError.fileMissing)
return
}
do {
// Attempt to read the contents
let data = try Data(contentsOf: url)
self.lastReadDate = Date()
continuation.resume(returning: data)
} catch {
continuation.resume(throwing: error)
}
}
// Pass the error along
if let error {
continuation.resume(throwing: error)
}
}
}
/// Write data to the presented URL.
///
/// This method coordinates to asynchronously write the contents of the file.
func writeData(_ data: Data) async throws {
// We need a file to write to
guard let presentedItemURL else { throw StorageError.noURL }
try await withCheckedThrowingContinuation { continuation in
var error: NSError? // A write error
// Coordinate writing to the file
coordinator.coordinate(writingItemAt: presentedItemURL, error: &error) { url in
do {
// Attempt to write the data
try data.write(to: url, options: [.atomic])
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
// Pass the error along
if let error {
continuation.resume(throwing: error)
}
}
}
}
extension StorageCoordinator {
public enum StorageError: Error {
case noURL
case fileMissing
}
}
//
// Store.swift
//
//
// Created by Emory Dunn on 7/25/23.
//
import Foundation
import Collections
import SwiftUI
import OSLog
fileprivate var log = Logger(subsystem: "FSIDB", category: "Store")
struct Storage {
static var encoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = [
.prettyPrinted,
.sortedKeys,
.withoutEscapingSlashes
]
return encoder
}()
static var decoder: JSONDecoder = JSONDecoder()
}
public final class Store<Item>: ObservableObject where Item: Codable & Identifiable {
public typealias Items = OrderedDictionary<Item.ID, Item>
@MainActor @Published
public private(set) var items: Items = [:]
let coordinator: StorageCoordinator
public init(url: URL) {
self.coordinator = StorageCoordinator(url: url)
self.coordinator.itemDidChange = readData
Task {
try await self.readData()
}
}
public init(withoutReadingURL url: URL) {
self.coordinator = StorageCoordinator(url: url)
self.coordinator.itemDidChange = readData
}
// MARK: Persistence
/// Read the file and update the stored value
public func readData() async throws {
do {
// Read the data and decode
let data = try await coordinator.readData()
let array = try Storage.decoder.decode(Array<Item>.self, from: data)
await MainActor.run {
self.items = OrderedDictionary(identifiedArray: array)
log.log("Read \(self.items.count) \(type(of: Item.self)) from disk")
}
} catch StorageCoordinator.StorageError.fileMissing {
// If the file is missing we'll create it during the next write
log.info("Missing file, will create from current data")
} catch {
// Otherwise it's Error Time!
throw error
}
}
/// Write the current state to disk.
public func writeData(_ updatedItems: Items, persist: Bool = true) async throws {
if persist { // Here to show the issue still happens even without writing
let data = try Storage.encoder.encode(updatedItems.values.elements)
try await coordinator.writeData(data)
}
await MainActor.run {
self.items = updatedItems
}
}
// MARK: - Item Management
/// Insert a new item or replace an item with the same ID.
/// - Parameter item: The item to insert into to the store.
public func insert(_ item: Item) async throws {
var currentItems = await self.items
currentItems[item.id] = item
try await writeData(currentItems)
}
@MainActor func bindingUpdate(_ item: Item) {
self.items[item.id] = item
Task {
let data = try Storage.encoder.encode(self.items.values.elements)
try await coordinator.writeData(data)
}
}
/// Insert new items from a sequence.
/// - Parameter newItems: The new items to insert into the store.
public func insert(_ newItems: [Item]) async throws {
var currentItems = await self.items
for item in newItems {
currentItems[item.id] = item
}
try await writeData(currentItems)
await MainActor.run { [currentItems] in
self.items = currentItems
}
}
func replace(with newItems: [Item]) async throws {
let updated: Items = OrderedDictionary(identifiedArray: newItems)
try await writeData(updated)
}
/// Remove an item from the store.
/// - Parameter item: The item to remove from the store.
public func remove(_ item: Item) async throws {
var currentItems = await self.items
currentItems[item.id] = nil
try await writeData(currentItems)
}
public func remove(_ items: [Item]) async throws {
var currentItems = await self.items
for item in items {
currentItems[item.id] = nil
}
try await writeData(currentItems)
}
public func removeAll() async throws {
let currentItems: Items = [:]
try await writeData(currentItems)
}
@MainActor
public func binding(for item: Item) -> Binding<Item> {
Binding {
self.items[item.id]!
} set: { item in
self.bindingUpdate(newValue)
}
}
@MainActor
public func binding(for id: Item.ID) -> Binding<Item> {
Binding {
self.items[id]!
} set: { newValue in
self.bindingUpdate(newValue)
}
}
}
//
// Stored.swift
//
//
// Created by Emory Dunn on 7/24/23.
//
import Foundation
import SwiftUI
import Collections
@propertyWrapper
public struct Stored<Item: Codable & Identifiable>: DynamicProperty {
@ObservedObject
public var store: Store<Item>
public init(in store: Store<Item>) {
self.store = store
}
public var wrappedValue: [Item] {
store.items.values.elements
}
public var projectedValue: Store<Item> {
store
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment