diff --git a/README.md b/README.md index 2ed2c50..7cbdad0 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ func createWithFallback() -> ObjectStore { } ``` -swift-filestore does not require developers to create new struct/classes for your data model. For example, to use JSON serialization, just have your existing model conform to `JSONDataRepresentable`. +`swift-filestore` does not require developers to create new struct/classes for your data model. For example, to use JSON serialization, just have your existing model conform to `JSONDataRepresentable`. ```swift @@ -44,7 +44,8 @@ try await objectStore.write(key: model.id, namespace: "MyModels", object: model) ``` ## Object Change Stream -swift-filestore offers an object change subscription API via Swift Concurrency. + +`swift-filestore` offers an object change subscription API via Swift Concurrency. ```swift for try await model in await objectStore.observe(key: id, namespace: "MyModels", objectType: MyModel.self) { @@ -53,7 +54,8 @@ for try await model in await objectStore.observe(key: id, namespace: "MyModels", ``` ## Custom serialization/deserialization -If you are looking for non-json serializations, you may define your custom serialization/deserialization protocol as below: + +If you are looking for non-json serializations, you can define your custom serialization/deserialization protocol as below: ```swift @@ -75,3 +77,27 @@ struct MyModel: BinaryDataRepresentable { let value: String } ``` + +## PersistenceLog + +`swift-filestore` offers an immutable logging component named `PersistenceLog`. It allows developer to store records on the disk and flush them at the right time. It can be used as an alternative to in-memory logging, which may risk data loss because app can be terminated at any time by user or the system. + + +Below code demonstrates how to use `PersistenceLog` to store and send in-app analytic events: +```swift +//data model for the analytics log +struct AnalyticsEvent: Codable, JSONDataRepresentable { + let name: String + let metaData: String +} + +//initialization +let log = try PersistenceLogImpl(name: "analytics-log") + +//When new event is triggered +try await log.append(event1) + +//When it's time to flush and sent to remote server +let events = try await log.flush() +try await networkClient.sendAnalytics(events) +``` diff --git a/Sources/SwiftFileStore/PersistenceLog.swift b/Sources/SwiftFileStore/PersistenceLog.swift new file mode 100644 index 0000000..969a100 --- /dev/null +++ b/Sources/SwiftFileStore/PersistenceLog.swift @@ -0,0 +1,86 @@ +// +// File.swift +// +// +// Created by Jun Yan on 11/25/23. +// + +import Foundation + +/// A persistent log which supports append and flush operation. +/// This can be used as a persistent logging queue as an alternative to in-memory queue to prevent data losses if app is killed. +public protocol PersistenceLog { + + associatedtype Element: DataRepresentable + + func append(element: Element) async throws + + func flush() async throws -> [Element] +} + + +public actor PersistenceLogImpl: PersistenceLog where ElementType: DataRepresentable { + + public typealias Element = ElementType + + let fileURL: URL + + private let name: String + private let dirURL: URL + private let fileHandle: FileHandle + + public init(name: String) throws { + let applicationSupportDir = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let rootDir = applicationSupportDir.appendingPathComponent("persistence-log", isDirectory: true) + try FileManager.default.createDirIfNotExist(url: rootDir) + try self.init(name: name, rootDir: rootDir) + } + + init(name: String, rootDir: URL) throws { + self.name = name + self.dirURL = rootDir + let fileURL = rootDir.appendingPathComponent(name, isDirectory: false) + let success = FileManager.default.createFileIfNotExist(url: fileURL) + if !success { + throw "failed to create file at \(fileURL.absoluteString)" + } + self.fileHandle = try FileHandle(forUpdating: fileURL) + self.fileURL = fileURL + } + + public func append(element: ElementType) async throws { + try fileHandle.seekToEnd() + let data = try element.serialize() + let dataSize = UInt32(data.count) + let bytes: Data = withUnsafeBytes(of: dataSize) { Data($0) } + data + try fileHandle.write(contentsOf: bytes) + } + + public func flush() async throws -> [ElementType] { + try fileHandle.seek(toOffset: 0) + let fileData = try fileHandle.readToEnd() + try fileHandle.truncate(atOffset: 0) + return try fileData?.deserializeToArray() ?? [] + } +} + +extension Data { + + func deserializeToArray() throws -> [ElementType] where ElementType: DataRepresentable { + var result: [ElementType] = [] + var idx = 0 + let uint32Size = MemoryLayout.size + while idx < count { + let sizeData = subdata(in: idx ..< idx + uint32Size) + let size = sizeData.withUnsafeBytes { (rawPtr: UnsafeRawBufferPointer) in + rawPtr.load(as: UInt32.self) + } + idx += uint32Size + let elementData = subdata(in: idx ..< idx + Int(size)) + idx += Int(size) + let element = try ElementType.from(data: elementData) + result.append(element) + } + return result + } +} diff --git a/Tests/SwiftFileStoreTests/FileObjectStoreTests.swift b/Tests/SwiftFileStoreTests/FileObjectStoreTests.swift index 18ac335..67616fb 100644 --- a/Tests/SwiftFileStoreTests/FileObjectStoreTests.swift +++ b/Tests/SwiftFileStoreTests/FileObjectStoreTests.swift @@ -1,4 +1,3 @@ -import Combine import XCTest @testable import SwiftFileStore diff --git a/Tests/SwiftFileStoreTests/MemoryObjectStoreTests.swift b/Tests/SwiftFileStoreTests/MemoryObjectStoreTests.swift index a896901..64054a7 100644 --- a/Tests/SwiftFileStoreTests/MemoryObjectStoreTests.swift +++ b/Tests/SwiftFileStoreTests/MemoryObjectStoreTests.swift @@ -1,4 +1,3 @@ -import Combine import XCTest @testable import SwiftFileStore diff --git a/Tests/SwiftFileStoreTests/PersistenceLogTests.swift b/Tests/SwiftFileStoreTests/PersistenceLogTests.swift new file mode 100644 index 0000000..1901530 --- /dev/null +++ b/Tests/SwiftFileStoreTests/PersistenceLogTests.swift @@ -0,0 +1,28 @@ +// +// File.swift +// +// +// Created by Jun Yan on 11/25/23. +// + +import XCTest +@testable import SwiftFileStore + +final class PersistenceLogTests: XCTestCase { + + var log: (any TestObjectLog)! + + func test_append_flush() async throws { + log = try! PersistenceLogImpl(name: "test-queue") + let object1 = TestObject(value: 1) + let object2 = TestObject(value: 2) + try await log.append(element: object1) + try await log.append(element: object2) + let result = try await log.flush() + XCTAssertEqual(result, [object1, object2]) + } +} + +protocol TestObjectLog: PersistenceLog where Element == TestObject {} + +extension PersistenceLogImpl: TestObjectLog {}