diff --git a/README.md b/README.md index 30e6b93..29d5731 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Please check the [releases](https://github.com/mochidev/CodableDatastore/release dependencies: [ .package( url: "https://github.com/mochidev/CodableDatastore.git", - .upToNextMinor(from: "0.3.4") + .upToNextMinor(from: "0.3.8") ), ], ... diff --git a/Sources/CodableDatastore/Datastore/Configuration.swift b/Sources/CodableDatastore/Datastore/Configuration.swift index 597778c..7c4401e 100644 --- a/Sources/CodableDatastore/Datastore/Configuration.swift +++ b/Sources/CodableDatastore/Datastore/Configuration.swift @@ -21,5 +21,8 @@ public struct Configuration: Sendable { ) { self.pageSize = pageSize } - + + static let minimumPageSize = 4*1024 + static let defaultPageSize = 4*1024 + static let maximumPageSize = 1024*1024*1024 } diff --git a/Sources/CodableDatastore/Persistence/DatastoreInterfaceError.swift b/Sources/CodableDatastore/Persistence/DatastoreInterfaceError.swift index 332e1e7..680bd04 100644 --- a/Sources/CodableDatastore/Persistence/DatastoreInterfaceError.swift +++ b/Sources/CodableDatastore/Persistence/DatastoreInterfaceError.swift @@ -46,27 +46,27 @@ public enum DatastoreInterfaceError: LocalizedError { public var errorDescription: String? { switch self { case .multipleRegistrations: - return "The datastore has already been registered with another persistence. Make sure to only register a datastore with a single persistence." + "The datastore has already been registered with another persistence. Make sure to only register a datastore with a single persistence." case .alreadyRegistered: - return "The datastore has already been registered with this persistence. Make sure to not call register multiple times per persistence." + "The datastore has already been registered with this persistence. Make sure to not call register multiple times per persistence." case .datastoreNotFound: - return "The datastore was not found and has likely not been registered with this persistence." + "The datastore was not found and has likely not been registered with this persistence." case .duplicateWriters: - return "An existing datastore that can write to the persistence has already been registered for this key. Only one writer is suppored per key." + "An existing datastore that can write to the persistence has already been registered for this key. Only one writer is suppored per key." case .instanceNotFound: - return "The requested instance could not be found with the specified identifier." + "The requested instance could not be found with the specified identifier." case .instanceAlreadyExists: - return "The requested insertion cursor conflicts with an already existing identifier." + "The requested insertion cursor conflicts with an already existing identifier." case .datastoreKeyNotFound: - return "The datastore being manipulated does not yet exist in the persistence." + "The datastore being manipulated does not yet exist in the persistence." case .indexNotFound: - return "The index being manipulated does not yet exist in the datastore." + "The index being manipulated does not yet exist in the datastore." case .transactionInactive: - return "The transaction was accessed outside of its activity window. Please make sure the transaction wasn't escaped." + "The transaction was accessed outside of its activity window. Please make sure the transaction wasn't escaped." case .unknownCursor: - return "The cursor does not match the one provided by the persistence." + "The cursor does not match the one provided by the persistence." case .staleCursor: - return "The cursor no longer refers to fresh data. Please make sure to use them as soon as possible and not interspaced with other writes." + "The cursor no longer refers to fresh data. Please make sure to use them as soon as possible and not interspaced with other writes." } } } diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastoreIndex.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastoreIndex.swift index cdb2746..dde9bc6 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastoreIndex.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastoreIndex.swift @@ -19,7 +19,7 @@ extension DiskPersistence.Datastore { actor Index: Identifiable { let datastore: DiskPersistence.Datastore - let id: ID + let id: PersistenceDatastoreIndexID var _manifest: DatastoreIndexManifest? var manifestTask: Task? @@ -30,7 +30,7 @@ extension DiskPersistence.Datastore { init( datastore: DiskPersistence.Datastore, - id: ID, + id: PersistenceDatastoreIndexID, manifest: DatastoreIndexManifest? = nil ) { self.datastore = datastore @@ -50,7 +50,7 @@ extension DiskPersistence.Datastore { // MARK: Hashable extension DiskPersistence.Datastore.Index: Hashable { - static func == (lhs: DiskPersistence.Datastore.Index, rhs: DiskPersistence.Datastore.Index) -> Bool { + static func == (lhs: DiskPersistence.Datastore.Index, rhs: DiskPersistence.Datastore.Index) -> Bool { lhs === rhs } @@ -61,8 +61,10 @@ extension DiskPersistence.Datastore.Index: Hashable { // MARK: - Helper Types -extension DiskPersistence.Datastore.Index { - enum ID: Hashable { +typealias PersistenceDatastoreIndexID = DiskPersistence.Datastore.IndexID + +extension DiskPersistence.Datastore { + enum IndexID: Hashable { case primary(manifest: DatastoreIndexManifestIdentifier) case direct(index: DatastoreIndexIdentifier, manifest: DatastoreIndexManifestIdentifier) case secondary(index: DatastoreIndexIdentifier, manifest: DatastoreIndexManifestIdentifier) @@ -867,13 +869,13 @@ extension DiskPersistence.Datastore.Index { func manifest( inserting entry: DatastorePageEntry, at insertionCursor: DiskPersistence.InsertionCursor, - targetPageSize: Int = 4*1024 + targetPageSize: Int = Configuration.defaultPageSize ) async throws -> ( manifest: DatastoreIndexManifest, createdPages: Set, removedPages: Set ) { - let actualPageSize = max(targetPageSize, 4*1024) - DiskPersistence.Datastore.Page.headerSize + let actualPageSize = min(max(targetPageSize, Configuration.minimumPageSize), Configuration.maximumPageSize) - DiskPersistence.Datastore.Page.headerSize guard insertionCursor.datastore === datastore, @@ -1092,7 +1094,7 @@ extension DiskPersistence.Datastore.Index { func manifest( replacing entry: DatastorePageEntry, at instanceCursor: DiskPersistence.InstanceCursor, - targetPageSize: Int = 4*1024 + targetPageSize: Int = Configuration.defaultPageSize ) async throws -> ( manifest: DatastoreIndexManifest, createdPages: Set, @@ -1107,7 +1109,7 @@ extension DiskPersistence.Datastore.Index { firstInstanceBlock.pageIndex != lastInstanceBlock.pageIndex || firstInstanceBlock.blockIndex <= lastInstanceBlock.blockIndex else { throw DatastoreInterfaceError.staleCursor } - let actualPageSize = max(targetPageSize, 4*1024) - DiskPersistence.Datastore.Page.headerSize + let actualPageSize = min(max(targetPageSize, Configuration.minimumPageSize), Configuration.maximumPageSize) - DiskPersistence.Datastore.Page.headerSize var manifest = try await manifest @@ -1289,7 +1291,7 @@ extension DiskPersistence.Datastore.Index { func manifest( deletingEntryAt instanceCursor: DiskPersistence.InstanceCursor, - targetPageSize: Int = 4*1024 + targetPageSize: Int = Configuration.defaultPageSize ) async throws -> ( manifest: DatastoreIndexManifest, createdPages: Set, @@ -1304,7 +1306,7 @@ extension DiskPersistence.Datastore.Index { firstInstanceBlock.pageIndex != lastInstanceBlock.pageIndex || firstInstanceBlock.blockIndex <= lastInstanceBlock.blockIndex else { throw DatastoreInterfaceError.staleCursor } - let actualPageSize = max(targetPageSize, 4*1024) - DiskPersistence.Datastore.Page.headerSize + let actualPageSize = min(max(targetPageSize, Configuration.minimumPageSize), Configuration.maximumPageSize) - DiskPersistence.Datastore.Page.headerSize var manifest = try await manifest @@ -1496,3 +1498,77 @@ extension DiskPersistence.Datastore.Index { return (manifest: manifest, removedPages: removedPages) } } + +// MARK: - Snapshotting + +extension DiskPersistence.Datastore.Index { + @discardableResult + func copy( + into newDatastore: DiskPersistence.Datastore, + rootObjectManifest: inout DatastoreRootManifest, + targetPageSize: Int + ) async throws -> DiskPersistence.Datastore.Index { + let actualPageSize = min(max(targetPageSize, Configuration.minimumPageSize), Configuration.maximumPageSize) - DiskPersistence.Datastore.Page.headerSize + + var newIndexManifest = DatastoreIndexManifest( + id: self.id.manifestID, + orderedPages: [] + ) + + let stream = AsyncThrowingBackpressureStream { continuation in + try await self.forwardScanEntries(after: self.firstInsertionCursor) { entry in + try await continuation.yield(entry) + return true + } + } + + var currentPageBlocks: [DatastorePageEntryBlock] = [] + var remainingSpace = actualPageSize + + var count = 0 + + for try await entry in stream { + count += 1 + let blocks = entry.blocks(remainingPageSpace: remainingSpace, maxPageSpace: actualPageSize) + + for block in blocks { + let encodedSize = block.encodedSize + if encodedSize > remainingSpace { + let newPage = DiskPersistence.Datastore.Page( + datastore: newDatastore, + id: .init(index: id, page: .init()), + blocks: currentPageBlocks + ) + newIndexManifest.orderedPages.append(.added(newPage.id.page)) + try await newPage.persistIfNeeded() + + currentPageBlocks.removeAll(keepingCapacity: true) + remainingSpace = actualPageSize + } + + remainingSpace -= encodedSize + currentPageBlocks.append(block) + } + } + + if !currentPageBlocks.isEmpty { + let newPage = DiskPersistence.Datastore.Page( + datastore: newDatastore, + id: .init(index: id, page: .init()), + blocks: currentPageBlocks + ) + newIndexManifest.orderedPages.append(.added(newPage.id.page)) + try await newPage.persistIfNeeded() + } + + rootObjectManifest.addedIndexes.insert(.init(id)) + rootObjectManifest.addedIndexManifests.insert(.init(id)) + let newIndex = DiskPersistence.Datastore.Index( + datastore: newDatastore, + id: id, + manifest: newIndexManifest + ) + try await newIndex.persistIfNeeded() + return newIndex + } +} diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastorePage.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastorePage.swift index 869cb0a..30884dc 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastorePage.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastorePage.swift @@ -16,7 +16,7 @@ extension DiskPersistence.Datastore { actor Page: Identifiable { let datastore: DiskPersistence.Datastore - let id: ID + let id: PersistenceDatastorePageID var blocksReaderTask: Task>, Error>? @@ -24,7 +24,7 @@ extension DiskPersistence.Datastore { init( datastore: DiskPersistence.Datastore, - id: ID, + id: PersistenceDatastorePageID, blocks: [DatastorePageEntryBlock]? = nil ) { self.datastore = datastore @@ -59,9 +59,11 @@ extension DiskPersistence.Datastore.Page: Hashable { // MARK: - Helper Types -extension DiskPersistence.Datastore.Page { - struct ID: Hashable { - let index: DiskPersistence.Datastore.Index.ID +typealias PersistenceDatastorePageID = DiskPersistence.Datastore.PageID + +extension DiskPersistence.Datastore { + struct PageID: Hashable { + let index: PersistenceDatastoreIndexID let page: DatastorePageIdentifier var withoutManifest: Self { @@ -105,7 +107,7 @@ extension DiskPersistence.Datastore.Page { try await iterator.check(Self.header) /// Pages larger than 1 GB are unsupported. - let transformation = try await iterator.collect(max: 1024*1024*1024) { sequence in + let transformation = try await iterator.collect(max: Configuration.maximumPageSize) { sequence in sequence.iteratorMap { iterator in guard let block = try await iterator.next(DatastorePageEntryBlock.self) else { throw DiskPersistenceError.invalidPageFormat } diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastoreRootManifest.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastoreRootManifest.swift index 4a0a799..ce17602 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastoreRootManifest.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastoreRootManifest.swift @@ -66,6 +66,14 @@ extension DatastoreRootManifest { case primary case direct(index: DatastoreIndexIdentifier) case secondary(index: DatastoreIndexIdentifier) + + init(_ id: PersistenceDatastoreIndexID) { + switch id { + case .primary: self = .primary + case .direct(let index, _): self = .direct(index: index) + case .secondary(let index, _): self = .secondary(index: index) + } + } } enum IndexManifestID: Codable, Hashable { @@ -73,7 +81,7 @@ extension DatastoreRootManifest { case direct(index: DatastoreIndexIdentifier, manifest: DatastoreIndexManifestIdentifier) case secondary(index: DatastoreIndexIdentifier, manifest: DatastoreIndexManifestIdentifier) - init(_ id: DiskPersistence.Datastore.Index.ID) { + init(_ id: PersistenceDatastoreIndexID) { switch id { case .primary(let manifest): self = .primary(manifest: manifest) diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/PersistenceDatastore.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/PersistenceDatastore.swift index f1ac416..e7f270b 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/PersistenceDatastore.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/PersistenceDatastore.swift @@ -279,3 +279,80 @@ extension DiskPersistence.Datastore { var hasObservers: Bool { !observers.isEmpty } } + +// MARK: - Snapshotting + +extension DiskPersistence.Datastore { + @discardableResult + func copy( + rootIdentifier: DatastoreRootIdentifier?, + datastoreKey: DatastoreKey, + into newSnapshot: Snapshot, + iteration: inout SnapshotIteration, + targetPageSize: Int + ) async throws -> DiskPersistence.Datastore { + let newDatastore = DiskPersistence.Datastore(id: id, snapshot: newSnapshot) + + /// Copy the datastore over. + iteration.dataStores[datastoreKey] = SnapshotIteration.DatastoreInfo(key: datastoreKey, id: id, root: rootIdentifier) + + /// Record the addition of a new datastore since it lives in a new location on disk. + iteration.addedDatastores.insert(id) + + /// Stop here if there are no roots for the datastore — there is nothing else to migrate. + guard let datastoreRootIdentifier = rootIdentifier + else { return newDatastore } + + /// Record the addition of a new datastore since it lives in a new location on disk. + iteration.addedDatastoreRoots.insert(DatastoreRootReference( + datastoreID: id, + datastoreRootID: datastoreRootIdentifier + )) + + let rootObject = rootObject(for: datastoreRootIdentifier) + let rootObjectManifest = try await rootObject.manifest + var newRootObjectManifest = DatastoreRootManifest( + id: rootObjectManifest.id, + modificationDate: rootObjectManifest.modificationDate, + descriptor: rootObjectManifest.descriptor, + primaryIndexManifest: rootObjectManifest.primaryIndexManifest, + directIndexManifests: rootObjectManifest.directIndexManifests, + secondaryIndexManifests: rootObjectManifest.secondaryIndexManifests, + addedIndexes: [], + removedIndexes: [], + addedIndexManifests: [], + removedIndexManifests: [] + ) + + try await rootObject.primaryIndex.copy( + into: newDatastore, + rootObjectManifest: &newRootObjectManifest, + targetPageSize: targetPageSize + ) + + for index in try await rootObject.directIndexes.values { + try await index.copy( + into: newDatastore, + rootObjectManifest: &newRootObjectManifest, + targetPageSize: targetPageSize + ) + } + + for index in try await rootObject.secondaryIndexes.values { + try await index.copy( + into: newDatastore, + rootObjectManifest: &newRootObjectManifest, + targetPageSize: targetPageSize + ) + } + + let newRootObject = DiskPersistence.Datastore.RootObject( + datastore: newDatastore, + id: datastoreRootIdentifier, + rootObject: newRootObjectManifest + ) + try await newRootObject.persistIfNeeded() + + return newDatastore + } +} diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/DatedIdentifier.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/DatedIdentifier.swift index e6537e1..58e2712 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/DatedIdentifier.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/DatedIdentifier.swift @@ -72,6 +72,21 @@ struct DatedIdentifierComponents { "\(hour)-\(minute)" } + var date: Date? { + DateComponents( + calendar: Calendar(identifier: .gregorian), + timeZone: TimeZone(secondsFromGMT: 0), + year: Int(year), + month: Int(month), + day: Int(day), + hour: Int(hour), + minute: Int(minute), + second: Int(second), + nanosecond: Int(millisecond).map { $0*1_000_000 } + ) + .date + } + static let size = 40 } diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistence.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistence.swift index 8306943..877d4dc 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistence.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistence.swift @@ -273,7 +273,7 @@ extension DiskPersistence { @_disfavoredOverload func withStoreInfo( accessor: @Sendable (_ storeInfo: StoreInfo) async throws -> T - ) async throws -> T where AccessMode == ReadOnly { + ) async throws -> T { try await withoutActuallyEscaping(accessor) { escapingClosure in try await updateStoreInfo(accessor: escapingClosure).value } @@ -284,8 +284,16 @@ extension DiskPersistence { extension DiskPersistence { /// Load the default snapshot from disk, or create an empty one if such a file does not exist. - private func loadSnapshot(from storeInfo: StoreInfo) -> Snapshot { - let snapshotID = storeInfo.currentSnapshot ?? SnapshotIdentifier() + /// + /// - Parameters: + /// - storeInfo: The store infor to load from. + /// - newSnapshotIdentifier: A new snapshot identifier to use if the store doesn't have one yet. If nil, a new one will be created automatically. + /// - Returns: A snapshot to start using. + private func loadSnapshot( + from storeInfo: StoreInfo, + newSnapshotIdentifier: SnapshotIdentifier? = nil + ) -> Snapshot { + let snapshotID = storeInfo.currentSnapshot ?? newSnapshotIdentifier ?? SnapshotIdentifier() if let snapshot = snapshots[snapshotID] { return snapshot @@ -378,6 +386,69 @@ extension DiskPersistence { try await updateCurrentSnapshot(accessor: escapingClosure).value } } + + /// Create a new snapshot from the current snapshot the persistence is pointing to. + /// + /// This method is temporarily publc — once all the components are public, you should use them directly before the next minor version. + public func _takeSnapshot() async throws where AccessMode == ReadWrite { + try await _takeSnapshot(newSnapshotIdentifier: nil) + } + + func _takeSnapshot( + newSnapshotIdentifier: SnapshotIdentifier? + ) async throws where AccessMode == ReadWrite { // TODO: return new snapshot iteration + let readSnapshot = try await currentSnapshot + let newSnapshot = try await createSnapshot(from: readSnapshot, newSnapshotIdentifier: newSnapshotIdentifier) + try await setCurrentSnapshot(snapshot: newSnapshot) + } + + /// Load the current snapshot the persistence is reading and writing to. + var currentSnapshot: Snapshot { + // TODO: This should return a readonly snapshot, but we need to be able to make a read-only copy from the persistence first. + get async throws { + try await withStoreInfo { await loadSnapshot(from: $0) } + } + } + + func setCurrentSnapshot( + snapshot: Snapshot, + dateUpdate: ModificationUpdate = .updateOnWrite + ) async throws where AccessMode == ReadWrite { + try await withStoreInfo { storeInfo in + guard snapshot.persistence === self + else { throw DiskPersistenceError.wrongPersistence } + + /// Update the store info with snapshot and modification date to use + storeInfo.currentSnapshot = snapshot.id + storeInfo.modificationDate = dateUpdate.modificationDate(for: storeInfo.modificationDate) + } + } + + func loadSnapshot(id: SnapshotIdentifier) async throws -> Snapshot { + preconditionFailure("Unimplemented") + } + +// var allSnapshots: AsyncStream> { +// /// Crawl the `Snapshots` directory, and collect all the .snapshot folders. Return the manifest structure. +// preconditionFailure("Unimplemented") +// } + + func createSnapshot( + from snapshot: Snapshot, // TODO: Shouldn't need to be readwrite + actionName: String? = nil, + newSnapshotIdentifier: SnapshotIdentifier? = nil + ) async throws -> Snapshot where AccessMode == ReadWrite { + let newSnapshot = try await snapshot.copy( + into: self, + actionName: actionName, + newSnapshotIdentifier: newSnapshotIdentifier, + targetPageSize: Configuration.defaultPageSize + ) + + /// Save a reference to this new snapshot, and return it. + snapshots[newSnapshot.id] = newSnapshot + return newSnapshot + } } // MARK: - Persistence Creation diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistenceError.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistenceError.swift index 0fbf03f..630141f 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistenceError.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistenceError.swift @@ -33,22 +33,27 @@ public enum DiskPersistenceError: LocalizedError, Equatable { /// The persistence is read only and cannot be written to. case cannotWrite + /// The persistence of another object is not in common with the persistence being used. + case wrongPersistence + public var errorDescription: String? { switch self { case .notFileURL: - return "The persistence store cannot be saved to the specified URL." + "The persistence store cannot be saved to the specified URL." case .missingBundleID: - return "The persistence store cannot be saved to the default URL as it is not running in the context of an app." + "The persistence store cannot be saved to the default URL as it is not running in the context of an app." case .missingAppSupportDirectory: - return "The persistence store cannot be saved to the default URL as an Application Support directory could built for this system." + "The persistence store cannot be saved to the default URL as an Application Support directory could built for this system." case .invalidIndexManifestFormat: - return "The index manifest was in a format that could not be understood." + "The index manifest was in a format that could not be understood." case .invalidPageFormat: - return "The page was in a format that could not be understood." + "The page was in a format that could not be understood." case .invalidEntryFormat: - return "The entry was in a format that could not be understood." + "The entry was in a format that could not be understood." case .cannotWrite: - return "The persistence is read only and cannot be written to." + "The persistence is read only and cannot be written to." + case .wrongPersistence: + "The persistence of another object is not in common with the persistence being used." } } } diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/Snapshot/Snapshot.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/Snapshot/Snapshot.swift index 15e15dd..6aad4ef 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/Snapshot/Snapshot.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/Snapshot/Snapshot.swift @@ -320,7 +320,10 @@ private enum SnapshotTaskLocals { // MARK: - Datastore Management extension Snapshot { /// Load the datastore for the given key. - func loadDatastore(for key: DatastoreKey, from iteration: SnapshotIteration) -> (DiskPersistence.Datastore, DatastoreRootIdentifier?) { + func loadDatastore( + for key: DatastoreKey, + from iteration: SnapshotIteration + ) -> (DiskPersistence.Datastore, DatastoreRootIdentifier?) { let datastoreInfo = if let info = iteration.dataStores[key] { (id: info.id, root: info.root) } else { @@ -337,3 +340,59 @@ extension Snapshot { return (datastore, datastoreInfo.root) } } + +// MARK: - Snapshotting + +extension Snapshot { + @discardableResult + func copy( + into persistence: DiskPersistence, + actionName: String? = nil, + newSnapshotIdentifier: SnapshotIdentifier? = nil, + targetPageSize: Int + ) async throws -> Snapshot { + try await readingManifest { manifest, iteration in + /// Create a new snapshot and iteration to load data into + let newSnapshot = Snapshot(id: newSnapshotIdentifier ?? SnapshotIdentifier(), persistence: persistence) + + let creationDate = (try? newSnapshot.id.components)?.date ?? Date() + var newIteration = SnapshotIteration( + id: SnapshotIterationIdentifier(rawValue: newSnapshot.id.rawValue), + creationDate: creationDate, + precedingIteration: iteration.id, + precedingSnapshot: id, + successiveIterations: [], + actionName: actionName, + dataStores: [:], + addedDatastores: [], + removedDatastores: [], + addedDatastoreRoots: [], + removedDatastoreRoots: [] + ) + + /// Iterate through each datastore and copy the data over + for (_, datastoreInfo) in iteration.dataStores { + let (datastore, _) = await loadDatastore(for: datastoreInfo.key, from: iteration) + try await datastore.copy( + rootIdentifier: datastoreInfo.root, + datastoreKey: datastoreInfo.key, + into: newSnapshot, + iteration: &newIteration, + targetPageSize: targetPageSize + ) + } + + /// Create a new manifest with our written data. + let newManifest = SnapshotManifest( + id: newSnapshot.id, + modificationDate: creationDate, + currentIteration: newIteration.id + ) + + /// Write the iteration and manifest records so the persistence is complete. + try await newSnapshot.write(iteration: newIteration) + try await newSnapshot.write(manifest: newManifest) + return newSnapshot + } + } +} diff --git a/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift b/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift index c3a25ec..b17e747 100644 --- a/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift +++ b/Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift @@ -914,4 +914,73 @@ final class DiskPersistenceDatastoreTests: XCTestCase, @unchecked Sendable { } } } + + func testTakingSnapshots() async throws { + struct TestFormat: DatastoreFormat { + enum Version: Int, CaseIterable { + case zero + } + + struct Instance: Codable, Identifiable { + var id: UUID = UUID() + var value: String + } + + static let defaultKey: DatastoreKey = "test" + static let currentVersion = Version.zero + } + + do { + let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) + try await persistence.createPersistenceIfNecessary() + + let datastore = Datastore.JSONStore( + persistence: persistence, + format: TestFormat.self, + migrations: [ + .zero: { data, decoder in + try decoder.decode(TestFormat.Instance.self, from: data) + } + ] + ) + + let valueBank = [ + "Hello, World!", + "My name is Dimitri", + "Writen using CodableDatastore", + "Swift is better than Objective-C, there, I said it", + "Twenty Three is Number One" + ] + + for _ in 1...100 { + try await datastore.persist(.init(value: valueBank.randomElement()!)) + } + + let count = try await datastore.count + let iteratedCount = try await datastore.load(...).reduce(into: 0) { partialResult, _ in partialResult += 1 } + XCTAssertEqual(count, iteratedCount) + } + + do { + let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL) + try await persistence._takeSnapshot() + + let datastore = Datastore.JSONStore( + persistence: persistence, + format: TestFormat.self, + migrations: [ + .zero: { data, decoder in + try decoder.decode(TestFormat.Instance.self, from: data) + } + ] + ) + + try await datastore.persist(.init(value: "hello")) + + let count = try await datastore.count + let iteratedCount = try await datastore.load(...).reduce(into: 0) { partialResult, _ in partialResult += 1 } + XCTAssertEqual(count, iteratedCount) + XCTAssertEqual(count, 101) + } + } }