Last active
July 28, 2023 19:37
-
-
Save emorydunn/193b6c66583650e727fac6277036cdc7 to your computer and use it in GitHub Desktop.
Coordinated Store
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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) | |
} | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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 | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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) | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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