diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastoreRoot.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastoreRoot.swift index 76f0cc8..4f48547 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastoreRoot.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastoreRoot.swift @@ -10,12 +10,46 @@ import Foundation typealias DatastoreRootIdentifier = DatedIdentifier.Datastore.RootObject> +/// A reference to a particular root within a datastore. +/// +/// Prior to version 0.4 (2024-10-11), only the root ID was stored in the snapshot iteration manifest's root file, which meant one would need to guess which datastore it belonged to. This type thus tries to decode both a single ``DatastoreRootIdentifier`` and a pair of ``DatastoreIdentifier`` and ``DatastoreRootIdentifier``s. +struct DatastoreRootReference: Codable, Hashable { + /// The datastore ID the reference points to. + /// + /// This may be nil when decoding from disk as a result of older iterations — when initializing a reference directly, it must not be nil. + var datastoreID: DatastoreIdentifier? + + /// The datastore root ID the reference points to. + var datastoreRootID: DatastoreRootIdentifier + + init(datastoreID: DatastoreIdentifier, datastoreRootID: DatastoreRootIdentifier) { + self.datastoreID = datastoreID + self.datastoreRootID = datastoreRootID + } + + init(from decoder: any Decoder) throws { + /// Attempt to decode a full object, otherwise fall back to a single value as it was prior to version 0.4 (2024-10-11). + do { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + self.datastoreID = try container.decodeIfPresent(DatastoreIdentifier.self, forKey: .datastoreID) + self.datastoreRootID = try container.decode(DatastoreRootIdentifier.self, forKey: .datastoreRootID) + } catch { + self.datastoreID = nil + self.datastoreRootID = try decoder.singleValueContainer().decode(DatastoreRootIdentifier.self) + } + } +} + extension DiskPersistence.Datastore { actor RootObject: Identifiable { let datastore: DiskPersistence.Datastore let id: DatastoreRootIdentifier + nonisolated var referenceID: DatastoreRootReference { + DatastoreRootReference(datastoreID: datastore.id, datastoreRootID: id) + } + var _rootObject: DatastoreRootManifest? var isPersisted: Bool diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistence.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistence.swift index ba11304..8306943 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistence.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistence.swift @@ -496,8 +496,8 @@ extension DiskPersistence { func persist( actionName: String?, roots: [DatastoreKey : Datastore.RootObject], - addedDatastoreRoots: Set, - removedDatastoreRoots: Set + addedDatastoreRoots: Set, + removedDatastoreRoots: Set ) async throws { let containsEdits = try await readingCurrentSnapshot { snapshot in try await snapshot.readingManifest { manifest, iteration in diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/Snapshot/SnapshotIteration.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/Snapshot/SnapshotIteration.swift index 4feb622..1663c5f 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/Snapshot/SnapshotIteration.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/Snapshot/SnapshotIteration.swift @@ -30,6 +30,11 @@ struct SnapshotIteration: Codable, Equatable, Identifiable { /// The iteration this one replaces. var precedingIteration: SnapshotIterationIdentifier? + /// The snapshot the preceding iteration belongs to. + /// + /// When set, the specified snapshot should be referenced to load the ``precedingIteration`` from. When `nil`, the current snapshot should be used. + var precedingSnapshot: SnapshotIdentifier? + /// The iterations that replace this one. /// /// If changes branched at this point in time, there may be more than one iteration to choose from. In this case, the first entry will be the oldest successor, while the last entry will be the most recent. @@ -48,10 +53,10 @@ struct SnapshotIteration: Codable, Equatable, Identifiable { var removedDatastores: Set = [] /// The datastore roots that have been added in this iteration of the snapshot. - var addedDatastoreRoots: Set = [] + var addedDatastoreRoots: Set = [] /// The datastore roots that have been replaced in this iteration of the snapshot. - var removedDatastoreRoots: Set = [] + var removedDatastoreRoots: Set = [] } extension SnapshotIteration { diff --git a/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift b/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift index 240cfc5..ec64246 100644 --- a/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift +++ b/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/Transaction.swift @@ -172,8 +172,8 @@ extension DiskPersistence { try await root.persistIfNeeded() } - let addedDatastoreRoots = Set(createdRootObjects.map(\.id)) - let removedDatastoreRoots = Set(deletedRootObjects.map(\.id)) + let addedDatastoreRoots = Set(createdRootObjects.map(\.referenceID)) + let removedDatastoreRoots = Set(deletedRootObjects.map(\.referenceID)) try await persistence.persist( actionName: actionName, diff --git a/Tests/CodableDatastoreTests/SnapshotIterationTests.swift b/Tests/CodableDatastoreTests/SnapshotIterationTests.swift new file mode 100644 index 0000000..e877970 --- /dev/null +++ b/Tests/CodableDatastoreTests/SnapshotIterationTests.swift @@ -0,0 +1,136 @@ +// +// SnapshotIterationTests.swift +// CodableDatastore +// +// Created by Dimitri Bouniol on 2025-02-16. +// Copyright © 2023-25 Mochi Development, Inc. All rights reserved. +// + +import XCTest +@testable import CodableDatastore + +final class SnapshotIterationTests: XCTestCase, @unchecked Sendable { + func testDecodingLegacyDatastoreRootReferences() throws { + let data = Data(""" + { + "addedDatastoreRoots" : [ + "2025-02-12 00-00-00-046 44BBE608B9CBF788" + ], + "addedDatastores" : [ + + ], + "creationDate" : "2025-02-12T00:00:00.057Z", + "dataStores" : { + "Store" : { + "id" : "Store-FD9BA6F1BD3667C8", + "key" : "Store", + "root" : "2024-08-24 09-39-57-775 66004A6BA331B89C" + } + }, + "id" : "2025-02-12 00-00-00-057 0130730F8F6A1ACC", + "precedingIteration" : "2025-02-11 23-59-54-727 447A1A1E1CF82177", + "removedDatastoreRoots" : [ + "2025-02-11 23-59-54-721 2AAEA12A38303055" + ], + "removedDatastores" : [ + + ], + "successiveIterations" : [ + + ], + "version" : "alpha" + } + """.utf8) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601WithMilliseconds + _ = try decoder.decode(SnapshotIteration.self, from: data) + } + + func testDecodingCurrentDatastoreRootReferences() throws { + let data = Data(""" + { + "addedDatastoreRoots" : [ + { + "datastoreID" : "Store-FD9BA6F1BD3667C8", + "datastoreRootID" : "2025-02-12 00-00-00-046 44BBE608B9CBF788" + } + ], + "addedDatastores" : [ + + ], + "creationDate" : "2025-02-12T00:00:00.057Z", + "dataStores" : { + "Store" : { + "id" : "Store-FD9BA6F1BD3667C8", + "key" : "Store", + "root" : "2024-08-24 09-39-57-775 66004A6BA331B89C" + } + }, + "id" : "2025-02-12 00-00-00-057 0130730F8F6A1ACC", + "precedingIteration" : "2025-02-11 23-59-54-727 447A1A1E1CF82177", + "removedDatastoreRoots" : [ + { + "datastoreID" : "Store-FD9BA6F1BD3667C8", + "datastoreRootID" : "2025-02-11 23-59-54-721 2AAEA12A38303055" + } + ], + "removedDatastores" : [ + + ], + "successiveIterations" : [ + + ], + "version" : "alpha" + } + """.utf8) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601WithMilliseconds + _ = try decoder.decode(SnapshotIteration.self, from: data) + } + + func testDecodingOptionalPrecedingSnapshotIdentifiers() throws { + let data = Data(""" + { + "addedDatastoreRoots" : [ + { + "datastoreID" : "Store-FD9BA6F1BD3667C8", + "datastoreRootID" : "2025-02-12 00-00-00-046 44BBE608B9CBF788" + } + ], + "addedDatastores" : [ + + ], + "creationDate" : "2025-02-12T00:00:00.057Z", + "dataStores" : { + "Store" : { + "id" : "Store-FD9BA6F1BD3667C8", + "key" : "Store", + "root" : "2024-08-24 09-39-57-775 66004A6BA331B89C" + } + }, + "id" : "2025-02-12 00-00-00-057 0130730F8F6A1ACC", + "precedingIteration" : "2025-02-11 23-59-54-727 447A1A1E1CF82177", + "precedingSnapshot" : "2024-04-14 13-09-27-739 A1EEB1A3AF102F15", + "removedDatastoreRoots" : [ + { + "datastoreID" : "Store-FD9BA6F1BD3667C8", + "datastoreRootID" : "2025-02-11 23-59-54-721 2AAEA12A38303055" + } + ], + "removedDatastores" : [ + + ], + "successiveIterations" : [ + + ], + "version" : "alpha" + } + """.utf8) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601WithMilliseconds + _ = try decoder.decode(SnapshotIteration.self, from: data) + } +}