.size).reversed() {
46 | diff = Int(leftPtr.load(fromByteOffset: offset, as: UInt8.self)) -
47 | Int(rightPtr.load(fromByteOffset: offset, as: UInt8.self))
48 | // Constant time, no branching equivalent of
49 | // if (diff != 0) {
50 | // result = diff;
51 | // }
52 | result = (result & (((diff - 1) & ~diff) >> 8)) | diff
53 | }
54 | }
55 | }
56 |
57 | return result < 0
58 | }
59 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/AccessMode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccessMode.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-05-10.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | /// An AccessMode marker type.
10 | public protocol _AccessMode {}
11 |
12 | /// A marker type that indicates read-only access.
13 | public enum ReadOnly: _AccessMode {}
14 |
15 | /// A marker type that indicates read-write access.
16 | public enum ReadWrite: _AccessMode {}
17 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Cursor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Cursor.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-17.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | /// An opaque type ``Persistence``s may use to indicate a position in their storage.
10 | ///
11 | /// - Note: A cursor is only valid within the same transaction for the same persistence it was created for.
12 | public protocol CursorProtocol: Sendable {
13 | associatedtype P: Persistence
14 | var persistence: P { get }
15 |
16 | // var transaction: Transaction
{ get }
17 | }
18 |
19 | /// An opaque type ``Persistence``s may use to indicate the position of an instance in their storage.
20 | public protocol InstanceCursorProtocol
: InsertionCursorProtocol {}
21 |
22 | /// An opaque type ``Persistence``s may use to indicate the position a new instance should be inserted in their storage.
23 | public protocol InsertionCursorProtocol
: CursorProtocol {}
24 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/DatastoreInterfaceError.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DatastoreInterfaceError.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-13.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// An error that may be returned from ``DatastoreInterfaceProtocol`` methods.
12 | public enum DatastoreInterfaceError: LocalizedError {
13 | /// The datastore has already been registered with another persistence.
14 | case multipleRegistrations
15 |
16 | /// The datastore has already been registered with this persistence.
17 | case alreadyRegistered
18 |
19 | /// The datastore was not found and has likely not been registered with this persistence.
20 | case datastoreNotFound
21 |
22 | /// An existing datastore that can write to the persistence has already been registered for this key.
23 | case duplicateWriters
24 |
25 | /// The requested instance could not be found with the specified identifier.
26 | case instanceNotFound
27 |
28 | /// The requested insertion cursor conflicts with an already existing identifier.
29 | case instanceAlreadyExists
30 |
31 | /// The datastore being manipulated does not yet exist in the persistence.
32 | case datastoreKeyNotFound
33 |
34 | /// The index being manipulated does not yet exist in the datastore.
35 | case indexNotFound
36 |
37 | /// The transaction was accessed outside of its activity window.
38 | case transactionInactive
39 |
40 | /// The cursor does not match the one provided by the persistence.
41 | case unknownCursor
42 |
43 | /// The cursor no longer refers to fresh data.
44 | case staleCursor
45 |
46 | public var errorDescription: String? {
47 | switch self {
48 | case .multipleRegistrations:
49 | "The datastore has already been registered with another persistence. Make sure to only register a datastore with a single persistence."
50 | case .alreadyRegistered:
51 | "The datastore has already been registered with this persistence. Make sure to not call register multiple times per persistence."
52 | case .datastoreNotFound:
53 | "The datastore was not found and has likely not been registered with this persistence."
54 | case .duplicateWriters:
55 | "An existing datastore that can write to the persistence has already been registered for this key. Only one writer is suppored per key."
56 | case .instanceNotFound:
57 | "The requested instance could not be found with the specified identifier."
58 | case .instanceAlreadyExists:
59 | "The requested insertion cursor conflicts with an already existing identifier."
60 | case .datastoreKeyNotFound:
61 | "The datastore being manipulated does not yet exist in the persistence."
62 | case .indexNotFound:
63 | "The index being manipulated does not yet exist in the datastore."
64 | case .transactionInactive:
65 | "The transaction was accessed outside of its activity window. Please make sure the transaction wasn't escaped."
66 | case .unknownCursor:
67 | "The cursor does not match the one provided by the persistence."
68 | case .staleCursor:
69 | "The cursor no longer refers to fresh data. Please make sure to use them as soon as possible and not interspaced with other writes."
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Disk Persistence/AsyncThrowingBackpressureStream.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AsyncThrowingBackpressureStream.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-07-10.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct AsyncThrowingBackpressureStream: Sendable {
12 | fileprivate actor StateMachine {
13 | var pendingEvents: [(CheckedContinuation, Result)] = []
14 | var eventsReadyContinuation: CheckedContinuation?
15 | var wasCancelled = false
16 |
17 | func provide(_ result: Result) async throws {
18 | guard !wasCancelled else { throw CancellationError() }
19 |
20 | try await withCheckedThrowingContinuation { continuation in
21 | precondition(pendingEvents.isEmpty, "More than one event has bee queued on the stream.")
22 | if let eventsReadyContinuation {
23 | self.eventsReadyContinuation = nil
24 | eventsReadyContinuation.resume(with: result)
25 | continuation.resume()
26 | } else {
27 | pendingEvents.append((continuation, result))
28 | }
29 | }
30 | }
31 |
32 | func consumeNext() async throws -> Element? {
33 | if Task.isCancelled {
34 | wasCancelled = true
35 | }
36 |
37 | return try await withCheckedThrowingContinuation { continuation in
38 | guard !pendingEvents.isEmpty else {
39 | eventsReadyContinuation = continuation
40 | return
41 | }
42 | let (providerContinuation, result) = pendingEvents.removeFirst()
43 | continuation.resume(with: result)
44 | if wasCancelled {
45 | providerContinuation.resume(throwing: CancellationError())
46 | } else {
47 | providerContinuation.resume()
48 | }
49 | }
50 | }
51 |
52 | deinit {
53 | if let eventsReadyContinuation {
54 | eventsReadyContinuation.resume(throwing: CancellationError())
55 | }
56 | }
57 | }
58 |
59 | struct Continuation: Sendable {
60 | private weak var stateMachine: StateMachine?
61 |
62 | fileprivate init(stateMachine: StateMachine) {
63 | self.stateMachine = stateMachine
64 | }
65 |
66 | func yield(_ value: Element) async throws {
67 | guard let stateMachine else { throw CancellationError() }
68 | try await stateMachine.provide(.success(value))
69 | }
70 |
71 | fileprivate func finish(throwing error: Error? = nil) async throws {
72 | guard let stateMachine else { throw CancellationError() }
73 | if let error {
74 | try await stateMachine.provide(.failure(error))
75 | } else {
76 | try await stateMachine.provide(.success(nil))
77 | }
78 | }
79 | }
80 |
81 | private var stateMachine: StateMachine
82 |
83 | init(provider: @Sendable @escaping (Continuation) async throws -> ()) {
84 | stateMachine = StateMachine()
85 |
86 | let continuation = Continuation(stateMachine: stateMachine)
87 | Task {
88 | do {
89 | try await provider(continuation)
90 | try await continuation.finish()
91 | } catch {
92 | try await continuation.finish(throwing: error)
93 | }
94 | }
95 | }
96 | }
97 |
98 | extension AsyncThrowingBackpressureStream: TypedAsyncSequence {
99 | struct AsyncIterator: AsyncIteratorProtocol {
100 | fileprivate var stateMachine: StateMachine
101 |
102 | func next() async throws -> Element? {
103 | try await stateMachine.consumeNext()
104 | }
105 | }
106 |
107 | func makeAsyncIterator() -> AsyncIterator {
108 | AsyncIterator(stateMachine: stateMachine)
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastoreIndexManifest.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DatastoreIndexManifest.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-26.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import AsyncSequenceReader
11 | import Bytes
12 |
13 | typealias DatastoreIndexManifestIdentifier = DatedIdentifier
14 |
15 | struct DatastoreIndexManifest: Equatable, Identifiable {
16 | /// The identifier for this manifest.
17 | var id: DatastoreIndexManifestIdentifier
18 |
19 | /// The version of the manifest, used when dealing with format changes at the library level.
20 | var version: Version = .alpha
21 |
22 | /// Pointers to the pages that make up the index.
23 | var orderedPages: [PageInfo]
24 |
25 | /// Pointers to the pageIDs currently in use by the index.
26 | var orderedPageIDs: some RandomAccessCollection {
27 | orderedPages.map { pageInfo -> DatastorePageIdentifier? in
28 | if case .removed = pageInfo { return nil }
29 |
30 | return pageInfo.id
31 | }
32 | }
33 |
34 | /// Pointers to the pageIDs removed by this iteration of the index.
35 | var removedPageIDs: some Sequence {
36 | orderedPages.lazy.compactMap { pageInfo -> DatastorePageIdentifier? in
37 | guard case .removed(let id) = pageInfo else { return nil }
38 | return id
39 | }
40 | }
41 |
42 | /// Pointers to the pageIDs added by this iteration of the index.
43 | var addedPageIDs: some Sequence {
44 | orderedPages.lazy.compactMap { pageInfo -> DatastorePageIdentifier? in
45 | guard case .added(let id) = pageInfo else { return nil }
46 | return id
47 | }
48 | }
49 | }
50 |
51 | // MARK: - Helper Types
52 |
53 | extension DatastoreIndexManifest {
54 | enum Version {
55 | case alpha
56 | }
57 |
58 | enum PageInfo: Equatable, Identifiable {
59 | case existing(DatastorePageIdentifier)
60 | case removed(DatastorePageIdentifier)
61 | case added(DatastorePageIdentifier)
62 |
63 | var id: DatastorePageIdentifier {
64 | switch self {
65 | case .existing(let id),
66 | .removed(let id),
67 | .added(let id):
68 | return id
69 | }
70 | }
71 |
72 | var isExisting: Bool {
73 | if case .existing = self { return true }
74 | return false
75 | }
76 |
77 | var isRemoved: Bool {
78 | if case .removed = self { return true }
79 | return false
80 | }
81 |
82 | var isAdded: Bool {
83 | if case .added = self { return true }
84 | return false
85 | }
86 | }
87 | }
88 |
89 | // MARK: - Decoding
90 |
91 | extension DatastoreIndexManifest {
92 | init(contentsOf url: URL, id: ID) async throws {
93 | #if canImport(Darwin)
94 | if #available(macOS 12.0, iOS 15, watchOS 8, tvOS 15, *) {
95 | try await self.init(sequence: AnyReadableSequence(url.resourceBytes), id: id)
96 | } else {
97 | try await self.init(sequence: AnyReadableSequence(try Data(contentsOf: url)), id: id)
98 | }
99 | #else
100 | try await self.init(sequence: AnyReadableSequence(try Data(contentsOf: url)), id: id)
101 | #endif
102 | }
103 |
104 | init(sequence: AnyReadableSequence, id: ID) async throws {
105 | self.id = id
106 |
107 | var iterator = sequence.makeAsyncIterator()
108 |
109 | try await iterator.check(utf8: "INDEX\n")
110 |
111 | self.version = .alpha
112 |
113 | var pages: [PageInfo] = []
114 |
115 | while let pageStatus = try await iterator.nextIfPresent(utf8: String.self, count: 1) {
116 | /// Fail early if the page status indicator is not supported.
117 | switch pageStatus {
118 | case " ", "+", "-": break
119 | default:
120 | throw DiskPersistenceError.invalidIndexManifestFormat
121 | }
122 |
123 | guard let pageNameBytes = try await iterator.collect(upToIncluding: Character("\n").asciiValue!, throwsIfOver: 1024) else {
124 | throw DiskPersistenceError.invalidIndexManifestFormat
125 | }
126 | /// Drop the new-line and turn it into an ID.
127 | let pageID = DatastorePageIdentifier(rawValue: String(utf8Bytes: pageNameBytes.dropLast(1)))
128 |
129 | switch pageStatus {
130 | case " ":
131 | pages.append(.existing(pageID))
132 | case "+":
133 | pages.append(.added(pageID))
134 | case "-":
135 | pages.append(.removed(pageID))
136 | default:
137 | throw DiskPersistenceError.invalidIndexManifestFormat
138 | }
139 | }
140 |
141 | /// Make sure we are at the end of the file
142 | guard try await iterator.next() == nil
143 | else { throw DiskPersistenceError.invalidIndexManifestFormat }
144 |
145 | self.orderedPages = pages
146 | }
147 | }
148 |
149 | // MARK: - Encoding
150 |
151 | extension DatastoreIndexManifest {
152 | var bytes: Bytes {
153 | var bytes = Bytes()
154 | /// 6 for the header, 1 for the page status, 36 for the ID, and 1 for the new line.
155 | bytes.reserveCapacity(6 + orderedPages.count*(1 + DatedIdentifierComponents.size + 1))
156 |
157 | bytes.append(contentsOf: "INDEX\n".utf8Bytes)
158 |
159 | for page in orderedPages {
160 | switch page {
161 | case .existing(let datastorePageIdentifier):
162 | bytes.append(contentsOf: " \(datastorePageIdentifier)\n".utf8Bytes)
163 | case .removed(let datastorePageIdentifier):
164 | bytes.append(contentsOf: "-\(datastorePageIdentifier)\n".utf8Bytes)
165 | case .added(let datastorePageIdentifier):
166 | bytes.append(contentsOf: "+\(datastorePageIdentifier)\n".utf8Bytes)
167 | }
168 | }
169 |
170 | return bytes
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastorePage.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DatastorePage.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-23.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import AsyncSequenceReader
11 | import Bytes
12 |
13 | typealias DatastorePageIdentifier = DatedIdentifier.Datastore.Page>
14 |
15 | extension DiskPersistence.Datastore {
16 | actor Page: Identifiable {
17 | let datastore: DiskPersistence.Datastore
18 |
19 | let id: PersistenceDatastorePageID
20 |
21 | var blocksReaderTask: Task>, Error>?
22 |
23 | var isPersisted: Bool
24 |
25 | init(
26 | datastore: DiskPersistence.Datastore,
27 | id: PersistenceDatastorePageID,
28 | blocks: [DatastorePageEntryBlock]? = nil
29 | ) {
30 | self.datastore = datastore
31 | self.id = id
32 | self.blocksReaderTask = blocks.map { blocks in
33 | Task {
34 | MultiplexedAsyncSequence(base: AnyReadableSequence(blocks))
35 | }
36 | }
37 | self.isPersisted = blocks == nil
38 | }
39 |
40 | deinit {
41 | Task { [id, datastore] in
42 | await datastore.invalidate(id)
43 | }
44 | }
45 | }
46 | }
47 |
48 | // MARK: Hashable
49 |
50 | extension DiskPersistence.Datastore.Page: Hashable {
51 | static func == (lhs: DiskPersistence.Datastore.Page, rhs: DiskPersistence.Datastore.Page) -> Bool {
52 | lhs === rhs
53 | }
54 |
55 | nonisolated func hash(into hasher: inout Hasher) {
56 | hasher.combine(id)
57 | }
58 | }
59 |
60 | // MARK: - Helper Types
61 |
62 | typealias PersistenceDatastorePageID = DiskPersistence.Datastore.PageID
63 |
64 | extension DiskPersistence.Datastore {
65 | struct PageID: Hashable {
66 | let index: PersistenceDatastoreIndexID
67 | let page: DatastorePageIdentifier
68 |
69 | var withoutManifest: Self {
70 | Self.init(
71 | index: index.with(manifestID: .init(rawValue: "")),
72 | page: page
73 | )
74 | }
75 | }
76 | }
77 |
78 | // MARK: - Common URL Accessors
79 |
80 | extension DiskPersistence.Datastore.Page {
81 | /// The URL that points to the page.
82 | nonisolated var pageURL: URL {
83 | datastore.pageURL(for: id)
84 | }
85 | }
86 |
87 | // MARK: - Persistence
88 |
89 | extension DiskPersistence.Datastore.Page {
90 | private var readableSequence: AnyReadableSequence {
91 | get throws {
92 | #if canImport(Darwin)
93 | if #available(macOS 12.0, iOS 15, watchOS 8, tvOS 15, *) {
94 | return AnyReadableSequence(pageURL.resourceBytes)
95 | } else {
96 | return AnyReadableSequence(try Data(contentsOf: pageURL))
97 | }
98 | #else
99 | return AnyReadableSequence(try Data(contentsOf: pageURL))
100 | #endif
101 | }
102 | }
103 |
104 | private nonisolated func performRead(sequence: AnyReadableSequence) async throws -> MultiplexedAsyncSequence> {
105 | var iterator = sequence.makeAsyncIterator()
106 |
107 | try await iterator.check(Self.header)
108 |
109 | /// Pages larger than 1 GB are unsupported.
110 | let transformation = try await iterator.collect(max: Configuration.maximumPageSize) { sequence in
111 | sequence.iteratorMap { iterator in
112 | guard let block = try await iterator.next(DatastorePageEntryBlock.self)
113 | else { throw DiskPersistenceError.invalidPageFormat }
114 | return block
115 | }
116 | }
117 |
118 | if let transformation {
119 | return MultiplexedAsyncSequence(base: AnyReadableSequence(transformation))
120 | } else {
121 | return MultiplexedAsyncSequence(base: AnyReadableSequence([]))
122 | }
123 | }
124 |
125 | var blocks: MultiplexedAsyncSequence> {
126 | get async throws {
127 | if let blocksReaderTask {
128 | return try await blocksReaderTask.value
129 | }
130 |
131 | let readerTask = Task {
132 | try await performRead(sequence: try readableSequence)
133 | }
134 | isPersisted = true
135 | blocksReaderTask = readerTask
136 | await datastore.mark(identifier: id, asLoaded: true)
137 |
138 | return try await readerTask.value
139 | }
140 | }
141 |
142 | func persistIfNeeded() async throws {
143 | guard !isPersisted else { return }
144 | let blocks = try await Array(blocks)
145 | let bytes = blocks.reduce(into: Self.header) { $0.append(contentsOf: $1.bytes) }
146 |
147 | let pageURL = pageURL
148 | /// Make sure the directories exists first.
149 | try FileManager.default.createDirectory(at: pageURL.deletingLastPathComponent(), withIntermediateDirectories: true)
150 |
151 | /// Write the bytes for the page to disk.
152 | try Data(bytes).write(to: pageURL, options: .atomic)
153 | isPersisted = true
154 | await datastore.mark(identifier: id, asLoaded: true)
155 | }
156 |
157 | static var header: Bytes { "PAGE\n".utf8Bytes }
158 | static var headerSize: Int { header.count }
159 | }
160 |
161 | actor MultiplexedAsyncSequence: AsyncSequence where Base.Element: Sendable, Base.AsyncIterator: Sendable, Base.AsyncIterator.Element: Sendable {
162 | typealias Element = Base.Element
163 |
164 | private var cachedEntries: [Task] = []
165 | private var baseIterator: Base.AsyncIterator?
166 |
167 | struct AsyncIterator: AsyncIteratorProtocol & Sendable {
168 | let base: MultiplexedAsyncSequence
169 | var index: Array.Index = 0
170 |
171 | mutating func next() async throws -> Element? {
172 | let index = index
173 | self.index += 1
174 | return try await base[index]
175 | }
176 | }
177 |
178 | private subscript(_ index: Int) -> Element? {
179 | get async throws {
180 | if index < cachedEntries.count {
181 | return try await cachedEntries[index].value
182 | }
183 |
184 | precondition(index == cachedEntries.count, "\(index) is out of bounds.")
185 |
186 | let lastTask: Task? = cachedEntries.last
187 |
188 | let newTask = Task {
189 | /// Make sure previous iteration finished before sourcing the next one.
190 | _ = try? await lastTask?.value
191 |
192 | /// Grab the next iteration, and save a reference back to it. This is only safe since we chain the requests behind previous ones.
193 | let (nextEntry, iteratorCopy) = try await nextBase(iterator: baseIterator)
194 | baseIterator = iteratorCopy
195 |
196 | return nextEntry
197 | }
198 | cachedEntries.append(newTask)
199 | return try await newTask.value
200 | }
201 | }
202 |
203 | /// Return the next base iterator to use along with the current entry, or nil if we've reached the end, so we don't retail the open file handles in our memory caches.
204 | nonisolated func nextBase(iterator: Base.AsyncIterator?) async throws -> (Element?, Base.AsyncIterator?) {
205 | var iteratorCopy = iterator
206 | let nextEntry = try await iteratorCopy?.next()
207 | return (nextEntry, nextEntry.flatMap { _ in iteratorCopy })
208 | }
209 |
210 | nonisolated func makeAsyncIterator() -> AsyncIterator {
211 | AsyncIterator(base: self)
212 | }
213 |
214 | init(base: Base) {
215 | baseIterator = base.makeAsyncIterator()
216 | }
217 | }
218 |
219 | extension RangeReplaceableCollection {
220 | init(_ sequence: S) async throws where S.Element == Element {
221 | self = try await sequence.reduce(into: Self.init()) { @Sendable partialResult, element in
222 | partialResult.append(element)
223 | }
224 | }
225 | }
226 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastorePageEntry.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DatastorePageEntry.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-27.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import AsyncSequenceReader
11 | import Bytes
12 |
13 | struct DatastorePageEntry: Hashable {
14 | var headers: [Bytes]
15 | var content: Bytes
16 |
17 | /// Whether the entry contains a complete header, but a partial content.
18 | var isPartial: Bool = false
19 | }
20 |
21 | // MARK: - Decoding
22 |
23 | extension DatastorePageEntry {
24 | init(bytes: Bytes, isPartial: Bool) throws {
25 | var iterator = bytes.makeIterator()
26 |
27 | var headers: [Bytes] = []
28 |
29 | let space = " ".utf8Bytes[0]
30 | let newline = "\n".utf8Bytes[0]
31 |
32 | repeat {
33 | /// First, check for a new line. If we get one, the header section is done.
34 | let nextByte = try iterator.next(Bytes.self, count: 1)[0]
35 | guard nextByte != newline else { break }
36 |
37 | /// Accumulate the following bytes until we encounter a space
38 | var headerSizeBytes = [nextByte]
39 | while let nextByte = iterator.next(), nextByte != space {
40 | headerSizeBytes.append(nextByte)
41 | }
42 |
43 | /// Decode those bytes as a decimal number
44 | let decimalSizeString = String(utf8Bytes: headerSizeBytes)
45 | guard let headerSize = Int(decimalSizeString), headerSize > 0, headerSize <= 8*1024
46 | else { throw DiskPersistenceError.invalidEntryFormat }
47 |
48 | /// Save the header
49 | headers.append(try iterator.next(Bytes.self, count: headerSize))
50 |
51 | /// Make sure it ends in a new line
52 | try iterator.check(utf8: "\n")
53 | } while true
54 |
55 | /// Just collect the rest of the bytes as the content.
56 | self.content = iterator.next(Bytes.self, max: bytes.count)
57 | self.headers = headers
58 | self.isPartial = isPartial
59 | }
60 | }
61 |
62 | // MARK: - Encoding
63 |
64 | extension DatastorePageEntry {
65 | var bytes: Bytes {
66 | var bytes = Bytes()
67 |
68 | var headerBytes = Bytes()
69 | for header in headers {
70 | headerBytes.append(contentsOf: String(header.count).utf8Bytes)
71 | headerBytes.append(contentsOf: " ".utf8Bytes)
72 | headerBytes.append(contentsOf: header)
73 | headerBytes.append(contentsOf: "\n".utf8Bytes)
74 | }
75 |
76 | bytes.reserveCapacity(headerBytes.count + 1 + content.count)
77 |
78 | bytes.append(contentsOf: headerBytes)
79 | bytes.append(contentsOf: "\n".utf8Bytes)
80 | bytes.append(contentsOf: content)
81 |
82 | return bytes
83 | }
84 |
85 | func blocks(remainingPageSpace: Int, maxPageSpace: Int) -> [DatastorePageEntryBlock] {
86 | precondition(remainingPageSpace >= 0, "remainingPageSpace must be greater or equal to zero.")
87 | precondition(maxPageSpace > 4, "maxPageSpace must be greater or equal to 5.")
88 |
89 | var remainingSlice = bytes[...]
90 | var blocks: [DatastorePageEntryBlock] = []
91 |
92 | if remainingPageSpace > 4 {
93 | var usableSpace = remainingPageSpace - 4
94 | var threshold = 10
95 | while usableSpace >= threshold {
96 | usableSpace -= 1
97 | threshold *= 10
98 | }
99 |
100 | guard remainingSlice.count > usableSpace else {
101 | return [.complete(Bytes(remainingSlice))]
102 | }
103 |
104 | let slice = remainingSlice.prefix(usableSpace)
105 | remainingSlice = remainingSlice.dropFirst(usableSpace)
106 | blocks.append(.head(Bytes(slice)))
107 | }
108 |
109 | while remainingSlice.count > 0 {
110 | var usableSpace = maxPageSpace - 4
111 | var threshold = 10
112 | while usableSpace >= threshold {
113 | usableSpace -= 1
114 | threshold *= 10
115 | }
116 |
117 | guard remainingSlice.count > usableSpace else {
118 | guard !blocks.isEmpty else {
119 | return [.complete(Bytes(remainingSlice))]
120 | }
121 |
122 | blocks.append(.tail(Bytes(remainingSlice)))
123 | return blocks
124 | }
125 |
126 | let slice = remainingSlice.prefix(usableSpace)
127 | remainingSlice = remainingSlice.dropFirst(usableSpace)
128 |
129 | if blocks.isEmpty {
130 | blocks.append(.head(Bytes(slice)))
131 | } else {
132 | blocks.append(.slice(Bytes(slice)))
133 | }
134 | }
135 |
136 | return blocks
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastorePageEntryBlock.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DatastorePageEntryBlock.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-27.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import AsyncSequenceReader
11 | import Bytes
12 |
13 | /// A block of data that represents a portion of an entry on a page.
14 | @usableFromInline
15 | enum DatastorePageEntryBlock: Hashable, Sendable {
16 | /// The tail end of an entry.
17 | ///
18 | /// This must be combined with a previous block to form an entry.
19 | case tail(Bytes)
20 |
21 | /// A complete entry.
22 | case complete(Bytes)
23 |
24 | /// The head end of an entry.
25 | ///
26 | /// This must be combined with a subsequent block to form an entry.
27 | case head(Bytes)
28 |
29 | /// A sclice of an entry.
30 | ///
31 | /// This must be combined with both the previous and subsequent blocks to form an entry.
32 | case slice(Bytes)
33 | }
34 |
35 | // MARK: - Decoding
36 |
37 | extension AsyncIteratorProtocol where Element == Byte {
38 | @usableFromInline
39 | mutating func next(_ type: DatastorePageEntryBlock.Type) async throws -> DatastorePageEntryBlock? {
40 | guard let blockType = try await nextIfPresent(utf8: String.self, count: 1) else {
41 | return nil
42 | }
43 |
44 | /// Fail early if the block type is not supported.
45 | switch blockType {
46 | case "<", "=", ">", "~": break
47 | default:
48 | throw DiskPersistenceError.invalidPageFormat
49 | }
50 |
51 | /// Artificially limit ourselves to ~ 9 GB, though realistically our limit will be much, much lower.
52 | guard let blockSizeBytes = try await collect(upToIncluding: "\n".utf8Bytes, throwsIfOver: 11)
53 | else { throw DiskPersistenceError.invalidPageFormat }
54 |
55 | let decimalSizeString = String(utf8Bytes: blockSizeBytes.dropLast(1))
56 | guard let blockSize = Int(decimalSizeString), blockSize > 0
57 | else { throw DiskPersistenceError.invalidPageFormat }
58 |
59 | let payload = try await next(Bytes.self, count: blockSize)
60 |
61 | try await check(utf8: "\n")
62 |
63 | switch blockType {
64 | case "<": return .tail(payload)
65 | case "=": return .complete(payload)
66 | case ">": return .head(payload)
67 | case "~": return .slice(payload)
68 | default: throw DiskPersistenceError.invalidPageFormat
69 | }
70 | }
71 | }
72 |
73 | // MARK: - Encoding
74 |
75 | extension DatastorePageEntryBlock {
76 | var bytes: Bytes {
77 | var bytes = Bytes()
78 |
79 | let blockType: String
80 | let payload: Bytes
81 |
82 | switch self {
83 | case .tail(let contents):
84 | blockType = "<"
85 | payload = contents
86 | case .complete(let contents):
87 | blockType = "="
88 | payload = contents
89 | case .head(let contents):
90 | blockType = ">"
91 | payload = contents
92 | case .slice(let contents):
93 | blockType = "~"
94 | payload = contents
95 | }
96 |
97 | let payloadSize = String(payload.count).utf8Bytes
98 |
99 | bytes.reserveCapacity(1 + payloadSize.count + 1 + payload.count + 1)
100 |
101 | bytes.append(contentsOf: blockType.utf8Bytes)
102 | bytes.append(contentsOf: payloadSize)
103 | bytes.append(contentsOf: "\n".utf8Bytes)
104 | bytes.append(contentsOf: payload)
105 | bytes.append(contentsOf: "\n".utf8Bytes)
106 |
107 | return bytes
108 | }
109 | }
110 |
111 | // MARK: - Sizing
112 |
113 | extension DatastorePageEntryBlock {
114 | var contents: Bytes {
115 | switch self {
116 | case .tail(let contents),
117 | .complete(let contents),
118 | .head(let contents),
119 | .slice(let contents):
120 | return contents
121 | }
122 | }
123 |
124 | var encodedSize: Int {
125 | let payload = contents
126 | let payloadSize = String(payload.count).utf8Bytes
127 |
128 | return 1 + payloadSize.count + 1 + payload.count + 1
129 | }
130 | }
131 |
132 | extension RandomAccessCollection where Element == DatastorePageEntryBlock {
133 | var encodedSize: Int {
134 | self.reduce(into: 0) { partialResult, block in
135 | partialResult += block.encodedSize
136 | }
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/DatastoreRootManifest.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DatastoreRootManifest.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-14.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// Versions supported by ``DiskPersisitence``.
12 | ///
13 | /// These are used when dealing with format changes at the library level.
14 | enum DatastoreRootManifestVersion: String, Codable {
15 | case alpha
16 | }
17 |
18 | struct DatastoreRootManifest: Codable, Equatable, Identifiable {
19 | /// The version of the root object, used when dealing with format changes at the library level.
20 | var version: DatastoreRootManifestVersion = .alpha
21 |
22 | /// The identifier for this root object.
23 | var id: DatastoreRootIdentifier
24 |
25 | /// The last modification date of the root object.
26 | var modificationDate: Date
27 |
28 | /// The descriptor of the datastore in its current state.
29 | var descriptor: DatastoreDescriptor
30 |
31 | /// A pointer to the primary index's root object.
32 | var primaryIndexManifest: DatastoreIndexManifestIdentifier
33 |
34 | /// A pointer to the direct indexes' root objects.
35 | var directIndexManifests: [IndexInfo] = []
36 |
37 | /// A pointer to the secondary indexes' root objects.
38 | var secondaryIndexManifests: [IndexInfo] = []
39 |
40 | /// The indexes that have been added in this iteration of the snapshot.
41 | var addedIndexes: Set = []
42 |
43 | /// The indexes that have been completely removed in this iteration of the snapshot.
44 | var removedIndexes: Set = []
45 |
46 | /// The datastore roots that have been added in this iteration of the snapshot.
47 | var addedIndexManifests: Set = []
48 |
49 | /// The datastore roots that have been replaced in this iteration of the snapshot.
50 | var removedIndexManifests: Set = []
51 | }
52 |
53 | extension DatastoreRootManifest {
54 | struct IndexInfo: Codable, Equatable, Identifiable {
55 | /// The key this index uses.
56 | var name: IndexName
57 |
58 | /// The identifier for the index on disk.
59 | var id: DatastoreIndexIdentifier
60 |
61 | /// The root object of the index.
62 | var root: DatastoreIndexManifestIdentifier
63 | }
64 |
65 | enum IndexID: Codable, Hashable {
66 | case primary
67 | case direct(index: DatastoreIndexIdentifier)
68 | case secondary(index: DatastoreIndexIdentifier)
69 |
70 | init(_ id: PersistenceDatastoreIndexID) {
71 | switch id {
72 | case .primary: self = .primary
73 | case .direct(let index, _): self = .direct(index: index)
74 | case .secondary(let index, _): self = .secondary(index: index)
75 | }
76 | }
77 | }
78 |
79 | enum IndexManifestID: Codable, Hashable {
80 | case primary(manifest: DatastoreIndexManifestIdentifier)
81 | case direct(index: DatastoreIndexIdentifier, manifest: DatastoreIndexManifestIdentifier)
82 | case secondary(index: DatastoreIndexIdentifier, manifest: DatastoreIndexManifestIdentifier)
83 |
84 | init(_ id: PersistenceDatastoreIndexID) {
85 | switch id {
86 | case .primary(let manifest):
87 | self = .primary(manifest: manifest)
88 | case .direct(let index, let manifest):
89 | self = .direct(index: index, manifest: manifest)
90 | case .secondary(let index, let manifest):
91 | self = .secondary(index: index, manifest: manifest)
92 | }
93 | }
94 |
95 | var indexID: DatastoreRootManifest.IndexID {
96 | switch self {
97 | case .primary(_): .primary
98 | case .direct(let id, _): .direct(index: id)
99 | case .secondary(let id, _): .secondary(index: id)
100 | }
101 | }
102 |
103 | var manifestID: DatastoreIndexManifestIdentifier {
104 | switch self {
105 | case .primary(let id): id
106 | case .direct(_, let id): id
107 | case .secondary(_, let id): id
108 | }
109 | }
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Disk Persistence/Datastore/PersistenceDatastore.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PersistenceDatastore.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-10.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | typealias DatastoreIdentifier = TypedIdentifier.Datastore>
12 |
13 | struct WeakValue {
14 | weak var value: T?
15 |
16 | init(_ value: T) {
17 | self.value = value
18 | }
19 | }
20 |
21 | extension DiskPersistence {
22 | actor Datastore {
23 | let id: DatastoreIdentifier
24 |
25 | unowned let snapshot: Snapshot
26 |
27 | var cachedRootObject: DatastoreRootManifest?
28 |
29 | var lastUpdateDescriptorTask: Task?
30 |
31 | /// The root objects that are being tracked in memory.
32 | var trackedRootObjects: [RootObject.ID : WeakValue] = [:]
33 | var trackedIndexes: [Index.ID : WeakValue] = [:]
34 | var trackedPages: [Page.ID : WeakValue] = [:]
35 |
36 | /// The root objects on the file system that are actively loaded in memory.
37 | var loadedRootObjects: Set = []
38 | var loadedIndexes: Set = []
39 | var loadedPages: Set = []
40 |
41 | typealias ObserverID = Int
42 | fileprivate var nextObserverID: ObserverID = 0
43 | var observers: [ObserverID : EventObserver] = [:]
44 |
45 | init(
46 | id: DatastoreIdentifier,
47 | snapshot: Snapshot
48 | ) {
49 | self.id = id
50 | self.snapshot = snapshot
51 | }
52 | }
53 | }
54 |
55 | // MARK: - Common URL Accessors
56 |
57 | extension DiskPersistence.Datastore {
58 | /// The URL that points to the Datastore directory.
59 | nonisolated var datastoreURL: URL {
60 | snapshot.datastoreURL(for: id)
61 | }
62 |
63 | /// The URL that points to the Root directory.
64 | nonisolated var rootURL: URL {
65 | datastoreURL
66 | .appendingPathComponent("Root", isDirectory: true)
67 | }
68 |
69 | /// The URL for the specified root.
70 | nonisolated func rootURL(for id: DatastoreRootIdentifier) -> URL {
71 | rootURL.appendingPathComponent("\(id).json", isDirectory: false)
72 | }
73 |
74 | /// The URL that points to the DirectIndexes directory.
75 | nonisolated var directIndexesURL: URL {
76 | datastoreURL.appendingPathComponent("DirectIndexes", isDirectory: true)
77 | }
78 |
79 | /// The URL that points to the SecondaryIndexes directory.
80 | nonisolated var secondaryIndexesURL: URL {
81 | datastoreURL.appendingPathComponent("SecondaryIndexes", isDirectory: true)
82 | }
83 |
84 | /// The root URL of a partifular index directory.
85 | nonisolated func indexURL(for indexID: DatastoreRootManifest.IndexID) -> URL {
86 | switch indexID {
87 | case .primary:
88 | directIndexesURL.appendingPathComponent("Primary.datastoreindex", isDirectory: true)
89 | case .direct(let indexID):
90 | directIndexesURL.appendingPathComponent("\(indexID).datastoreindex", isDirectory: true)
91 | case .secondary(let indexID):
92 | secondaryIndexesURL.appendingPathComponent("\(indexID).datastoreindex", isDirectory: true)
93 | }
94 | }
95 |
96 | /// The URL of an index's manifests directory.
97 | nonisolated func manifestsURL(for id: DatastoreRootManifest.IndexID) -> URL {
98 | indexURL(for: id).appendingPathComponent("Manifest", isDirectory: true)
99 | }
100 |
101 | /// The URL of an index's root manifest.
102 | nonisolated func manifestURL(for id: Index.ID) -> URL {
103 | manifestURL(for: DatastoreRootManifest.IndexManifestID(id))
104 | }
105 |
106 | /// The URL of an index's root manifest.
107 | nonisolated func manifestURL(for id: DatastoreRootManifest.IndexManifestID) -> URL {
108 | manifestsURL(for: id.indexID).appendingPathComponent("\(id.manifestID).indexmanifest", isDirectory: false)
109 | }
110 |
111 | /// The URL of an index's pages directory..
112 | nonisolated func pagesURL(for id: Index.ID) -> URL {
113 | indexURL(for: id.indexID).appendingPathComponent("Pages", isDirectory: true)
114 | }
115 |
116 | /// The URL of a particular page.
117 | nonisolated func pageURL(for id: Page.ID) -> URL {
118 | guard let components = try? id.page.components else { preconditionFailure("Components could not be determined for Page.") }
119 |
120 | return pagesURL(for: id.index)
121 | .appendingPathComponent(components.year, isDirectory: true)
122 | .appendingPathComponent(components.monthDay, isDirectory: true)
123 | .appendingPathComponent(components.hourMinute, isDirectory: true)
124 | .appendingPathComponent("\(id.page).datastorepage", isDirectory: false)
125 | }
126 | }
127 |
128 | // MARK: - Root Object Management
129 |
130 | extension DiskPersistence.Datastore {
131 | func rootObject(for identifier: RootObject.ID) -> RootObject {
132 | if let rootObject = trackedRootObjects[identifier]?.value {
133 | return rootObject
134 | }
135 | // print("🤷 Cache Miss: Root \(identifier)")
136 | let rootObject = RootObject(datastore: self, id: identifier)
137 | trackedRootObjects[identifier] = WeakValue(rootObject)
138 | Task { await snapshot.persistence.cache(rootObject) }
139 | return rootObject
140 | }
141 |
142 | func adopt(rootObject: RootObject) {
143 | trackedRootObjects[rootObject.id] = WeakValue(rootObject)
144 | Task { await snapshot.persistence.cache(rootObject) }
145 | }
146 |
147 | func invalidate(_ identifier: RootObject.ID) {
148 | trackedRootObjects.removeValue(forKey: identifier)
149 | }
150 |
151 | func mark(identifier: RootObject.ID, asLoaded: Bool) {
152 | if asLoaded {
153 | loadedRootObjects.insert(identifier)
154 | } else {
155 | loadedRootObjects.remove(identifier)
156 | }
157 | }
158 |
159 | func index(for identifier: Index.ID) -> Index {
160 | if let index = trackedIndexes[identifier]?.value {
161 | return index
162 | }
163 | // print("🤷 Cache Miss: Index \(identifier)")
164 | let index = Index(datastore: self, id: identifier)
165 | trackedIndexes[identifier] = WeakValue(index)
166 | Task { await snapshot.persistence.cache(index) }
167 | return index
168 | }
169 |
170 | func adopt(index: Index) {
171 | trackedIndexes[index.id] = WeakValue(index)
172 | Task { await snapshot.persistence.cache(index) }
173 | }
174 |
175 | func invalidate(_ identifier: Index.ID) {
176 | trackedIndexes.removeValue(forKey: identifier)
177 | }
178 |
179 | func mark(identifier: Index.ID, asLoaded: Bool) {
180 | if asLoaded {
181 | loadedIndexes.insert(identifier)
182 | } else {
183 | loadedIndexes.remove(identifier)
184 | }
185 | }
186 |
187 | func page(for identifier: Page.ID) -> Page {
188 | if let page = trackedPages[identifier.withoutManifest]?.value {
189 | return page
190 | }
191 | // print("🤷 Cache Miss: Page \(identifier.page)")
192 | let page = Page(datastore: self, id: identifier)
193 | trackedPages[identifier.withoutManifest] = WeakValue(page)
194 | Task { await snapshot.persistence.cache(page) }
195 | return page
196 | }
197 |
198 | func adopt(page: Page) {
199 | trackedPages[page.id.withoutManifest] = WeakValue(page)
200 | Task { await snapshot.persistence.cache(page) }
201 | }
202 |
203 | func invalidate(_ identifier: Page.ID) {
204 | trackedPages.removeValue(forKey: identifier.withoutManifest)
205 | }
206 |
207 | func mark(identifier: Page.ID, asLoaded: Bool) {
208 | if asLoaded {
209 | loadedPages.insert(identifier.withoutManifest)
210 | } else {
211 | loadedPages.remove(identifier.withoutManifest)
212 | }
213 | }
214 | }
215 |
216 | // MARK: - Root Object Management
217 |
218 | extension DiskPersistence.Datastore {
219 | /// Load the root object from disk for the given identifier.
220 | func loadRootObject(for rootIdentifier: DatastoreRootIdentifier) throws -> DatastoreRootManifest {
221 | let rootObjectURL = rootURL(for: rootIdentifier)
222 |
223 | let data = try Data(contentsOf: rootObjectURL)
224 |
225 | let root = try JSONDecoder.shared.decode(DatastoreRootManifest.self, from: data)
226 |
227 | cachedRootObject = root
228 | return root
229 | }
230 |
231 | /// Write the specified manifest to the store, and cache the results in ``DiskPersistence.Datastore/cachedRootObject``.
232 | func write(manifest: DatastoreRootManifest) throws where AccessMode == ReadWrite {
233 | /// Make sure the directories exists first.
234 | if cachedRootObject == nil {
235 | try FileManager.default.createDirectory(at: datastoreURL, withIntermediateDirectories: true)
236 | try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
237 | try FileManager.default.createDirectory(at: directIndexesURL, withIntermediateDirectories: true)
238 | try FileManager.default.createDirectory(at: secondaryIndexesURL, withIntermediateDirectories: true)
239 | }
240 |
241 | let rootObjectURL = rootURL(for: manifest.id)
242 |
243 | /// Encode the provided manifest, and write it to disk.
244 | let data = try JSONEncoder.shared.encode(manifest)
245 | try data.write(to: rootObjectURL, options: .atomic)
246 |
247 | /// Update the cache since we know what it should be.
248 | cachedRootObject = manifest
249 | }
250 | }
251 |
252 | // MARK: - Observations
253 |
254 | extension DiskPersistence.Datastore {
255 | func register(
256 | observer: DiskPersistence.EventObserver
257 | ) {
258 | let id = nextObserverID
259 | nextObserverID += 1
260 | observers[id] = observer
261 | observer.onTermination = { _ in
262 | Task {
263 | await self.unregisterObserver(for: id)
264 | }
265 | }
266 | }
267 |
268 | private func unregisterObserver(for id: ObserverID) {
269 | observers.removeValue(forKey: id)
270 | }
271 |
272 | func emit(
273 | _ event: ObservedEvent
274 | ) {
275 | for (_, observer) in observers {
276 | observer.yield(event)
277 | }
278 | }
279 |
280 | var hasObservers: Bool { !observers.isEmpty }
281 | }
282 |
283 | // MARK: - Snapshotting
284 |
285 | extension DiskPersistence.Datastore {
286 | @discardableResult
287 | func copy(
288 | rootIdentifier: DatastoreRootIdentifier?,
289 | datastoreKey: DatastoreKey,
290 | into newSnapshot: Snapshot,
291 | iteration: inout SnapshotIteration,
292 | targetPageSize: Int
293 | ) async throws -> DiskPersistence.Datastore {
294 | let newDatastore = DiskPersistence.Datastore(id: id, snapshot: newSnapshot)
295 |
296 | /// Copy the datastore over.
297 | iteration.dataStores[datastoreKey] = SnapshotIteration.DatastoreInfo(key: datastoreKey, id: id, root: rootIdentifier)
298 |
299 | /// Record the addition of a new datastore since it lives in a new location on disk.
300 | iteration.addedDatastores.insert(id)
301 |
302 | /// Stop here if there are no roots for the datastore — there is nothing else to migrate.
303 | guard let datastoreRootIdentifier = rootIdentifier
304 | else { return newDatastore }
305 |
306 | /// Record the addition of a new datastore since it lives in a new location on disk.
307 | iteration.addedDatastoreRoots.insert(DatastoreRootReference(
308 | datastoreID: id,
309 | datastoreRootID: datastoreRootIdentifier
310 | ))
311 |
312 | let rootObject = rootObject(for: datastoreRootIdentifier)
313 | let rootObjectManifest = try await rootObject.manifest
314 | var newRootObjectManifest = DatastoreRootManifest(
315 | id: rootObjectManifest.id,
316 | modificationDate: rootObjectManifest.modificationDate,
317 | descriptor: rootObjectManifest.descriptor,
318 | primaryIndexManifest: rootObjectManifest.primaryIndexManifest,
319 | directIndexManifests: rootObjectManifest.directIndexManifests,
320 | secondaryIndexManifests: rootObjectManifest.secondaryIndexManifests,
321 | addedIndexes: [],
322 | removedIndexes: [],
323 | addedIndexManifests: [],
324 | removedIndexManifests: []
325 | )
326 |
327 | try await rootObject.primaryIndex.copy(
328 | into: newDatastore,
329 | rootObjectManifest: &newRootObjectManifest,
330 | targetPageSize: targetPageSize
331 | )
332 |
333 | for index in try await rootObject.directIndexes.values {
334 | try await index.copy(
335 | into: newDatastore,
336 | rootObjectManifest: &newRootObjectManifest,
337 | targetPageSize: targetPageSize
338 | )
339 | }
340 |
341 | for index in try await rootObject.secondaryIndexes.values {
342 | try await index.copy(
343 | into: newDatastore,
344 | rootObjectManifest: &newRootObjectManifest,
345 | targetPageSize: targetPageSize
346 | )
347 | }
348 |
349 | let newRootObject = DiskPersistence.Datastore.RootObject(
350 | datastore: newDatastore,
351 | id: datastoreRootIdentifier,
352 | rootObject: newRootObjectManifest
353 | )
354 | try await newRootObject.persistIfNeeded()
355 |
356 | return newDatastore
357 | }
358 | }
359 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Disk Persistence/DatedIdentifier.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DatedIdentifier.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-08.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct DatedIdentifier: DatedIdentifierProtocol {
12 | var rawValue: String
13 |
14 | init(rawValue: String) {
15 | self.rawValue = rawValue
16 | }
17 | }
18 |
19 | protocol DatedIdentifierProtocol: TypedIdentifierProtocol {
20 | var rawValue: String { get }
21 | init(rawValue: String)
22 | }
23 |
24 | extension DatedIdentifierProtocol {
25 | init(
26 | date: Date = Date(),
27 | token: UInt64 = .random(in: UInt64.min...UInt64.max)
28 | ) {
29 | let stringToken = String(token, radix: 16, uppercase: true)
30 | self.init(rawValue: "\(DateFormatter.datedFormatter.string(from: date)) \(String(repeating: "0", count: 16-stringToken.count))\(stringToken)")
31 | }
32 |
33 | var components: DatedIdentifierComponents {
34 | get throws {
35 | try DatedIdentifierComponents(self)
36 | }
37 | }
38 | }
39 |
40 | struct DatedIdentifierComponents {
41 | var year: String
42 | var month: String
43 | var day: String
44 |
45 | var hour: String
46 | var minute: String
47 | var second: String
48 | var millisecond: String
49 |
50 | var token: String
51 |
52 | init(_ identifier: some DatedIdentifierProtocol) throws {
53 | let rawString = identifier.rawValue
54 | guard rawString.count == Self.size else {
55 | throw DatedIdentifierError.invalidLength
56 | }
57 |
58 | year = rawString[0..<4]
59 | month = rawString[5..<7]
60 | day = rawString[8..<10]
61 | hour = rawString[11..<13]
62 | minute = rawString[14..<16]
63 | second = rawString[17..<19]
64 | millisecond = rawString[20..<23]
65 | token = rawString[24..<40]
66 | }
67 |
68 | var monthDay: String {
69 | "\(month)-\(day)"
70 | }
71 | var hourMinute: String {
72 | "\(hour)-\(minute)"
73 | }
74 |
75 | var date: Date? {
76 | DateComponents(
77 | calendar: Calendar(identifier: .gregorian),
78 | timeZone: TimeZone(secondsFromGMT: 0),
79 | year: Int(year),
80 | month: Int(month),
81 | day: Int(day),
82 | hour: Int(hour),
83 | minute: Int(minute),
84 | second: Int(second),
85 | nanosecond: Int(millisecond).map { $0*1_000_000 }
86 | )
87 | .date
88 | }
89 |
90 | static let size = 40
91 | }
92 |
93 |
94 | enum DatedIdentifierError: LocalizedError, Equatable {
95 | case invalidLength
96 |
97 | var errorDescription: String? {
98 | switch self {
99 | case .invalidLength: return "The identifier must be \(DatedIdentifierComponents.size) characters long."
100 | }
101 | }
102 | }
103 |
104 | private extension StringProtocol {
105 | subscript (intRange: Range) -> String {
106 | let lowerBound = self.index(self.startIndex, offsetBy: intRange.lowerBound)
107 | let upperBound = self.index(self.startIndex, offsetBy: intRange.upperBound)
108 |
109 | return String(self[lowerBound..=6)
47 | extension ISO8601DateFormatter: @unchecked @retroactive Sendable {}
48 | extension JSONDecoder.DateDecodingStrategy: @unchecked Sendable {}
49 | extension JSONEncoder.DateEncodingStrategy: @unchecked Sendable {}
50 | #endif
51 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Disk Persistence/JSONCoder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // JSONCoder.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-14.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | extension JSONEncoder {
12 | static let shared: JSONEncoder = {
13 | let datastoreEncoder = JSONEncoder()
14 | datastoreEncoder.dateEncodingStrategy = .iso8601WithMilliseconds
15 | #if DEBUG
16 | datastoreEncoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
17 | #else
18 | datastoreEncoder.outputFormatting = [.withoutEscapingSlashes]
19 | #endif
20 | return datastoreEncoder
21 | }()
22 | }
23 |
24 | extension JSONDecoder {
25 | static let shared: JSONDecoder = {
26 | let datastoreDecoder = JSONDecoder()
27 | datastoreDecoder.dateDecodingStrategy = .iso8601WithMilliseconds
28 | return datastoreDecoder
29 | }()
30 | }
31 |
32 | #if !canImport(Darwin)
33 | extension JSONEncoder: @unchecked Sendable {}
34 | extension JSONDecoder: @unchecked Sendable {}
35 | #endif
36 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Disk Persistence/LazyTask.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LazyTask.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-07-05.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | struct LazyTask {
10 | let factory: @Sendable () async -> T
11 |
12 | var value: T {
13 | get async {
14 | await factory()
15 | }
16 | }
17 | }
18 |
19 | extension LazyTask: Sendable where T: Sendable {}
20 |
21 | struct LazyThrowingTask {
22 | let factory: @Sendable () async throws -> T
23 |
24 | var value: T {
25 | get async throws {
26 | try await factory()
27 | }
28 | }
29 | }
30 |
31 | extension LazyThrowingTask: Sendable where T: Sendable {}
32 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Disk Persistence/Snapshot/SnapshotIteration.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SnapshotIteration.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-07-15.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | typealias SnapshotIterationIdentifier = DatedIdentifier
12 |
13 | /// Versions supported by ``DiskPersisitence``.
14 | ///
15 | /// These are used when dealing with format changes at the library level.
16 | enum SnapshotIterationVersion: String, Codable {
17 | case alpha
18 | }
19 |
20 | /// A struct to store information about a ``DiskPersistence``'s snapshot on disk.
21 | struct SnapshotIteration: Codable, Equatable, Identifiable {
22 | /// The version of the snapshot iteration, used when dealing with format changes at the library level.
23 | var version: SnapshotIterationVersion = .alpha
24 |
25 | var id: SnapshotIterationIdentifier
26 |
27 | /// The date this iteration was created.
28 | var creationDate: Date
29 |
30 | /// The iteration this one replaces.
31 | var precedingIteration: SnapshotIterationIdentifier?
32 |
33 | /// The snapshot the preceding iteration belongs to.
34 | ///
35 | /// When set, the specified snapshot should be referenced to load the ``precedingIteration`` from. When `nil`, the current snapshot should be used.
36 | var precedingSnapshot: SnapshotIdentifier?
37 |
38 | /// The iterations that replace this one.
39 | ///
40 | /// 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.
41 | var successiveIterations: [SnapshotIterationIdentifier] = []
42 |
43 | /// The name of the action used that can be presented in a user interface.
44 | var actionName: String?
45 |
46 | /// The known datastores for this snapshot, and their roots.
47 | var dataStores: [String : DatastoreInfo] = [:]
48 |
49 | /// The datastores that have been added in this iteration of the snapshot.
50 | var addedDatastores: Set = []
51 |
52 | /// The datastores that have been completely removed in this iteration of the snapshot.
53 | var removedDatastores: Set = []
54 |
55 | /// The datastore roots that have been added in this iteration of the snapshot.
56 | var addedDatastoreRoots: Set = []
57 |
58 | /// The datastore roots that have been replaced in this iteration of the snapshot.
59 | var removedDatastoreRoots: Set = []
60 | }
61 |
62 | extension SnapshotIteration {
63 | struct DatastoreInfo: Codable, Equatable, Identifiable {
64 | /// The key this datastore uses.
65 | var key: DatastoreKey
66 |
67 | /// The identifier the datastore was saved under.
68 | var id: DatastoreIdentifier
69 |
70 | /// The root object for the datastore.
71 | var root: DatastoreRootIdentifier?
72 | }
73 | }
74 |
75 | extension SnapshotIteration {
76 | /// Internal method to check if an instance should be persisted based on iff it changed significantly from a previous iteration
77 | /// - Parameter existingInstance: The previous iteration to check
78 | /// - Returns: `true` if the iteration should be persisted, `false` if it represents the same data from `existingInstance`.
79 | func isMeaningfullyChanged(from existingInstance: Self?) -> Bool {
80 | guard
81 | dataStores == existingInstance?.dataStores
82 | else { return true }
83 | return false
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Disk Persistence/Snapshot/SnapshotManifest.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SnapshotManifest.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-08.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// Versions supported by ``DiskPersisitence``.
12 | ///
13 | /// These are used when dealing with format changes at the library level.
14 | enum SnapshotManifestVersion: String, Codable, Sendable {
15 | case alpha
16 | }
17 |
18 | /// A struct to store information about a ``DiskPersistence``'s snapshot on disk.
19 | struct SnapshotManifest: Codable, Equatable, Identifiable, Sendable {
20 | /// The version of the snapshot, used when dealing with format changes at the library level.
21 | var version: SnapshotManifestVersion = .alpha
22 |
23 | var id: SnapshotIdentifier
24 |
25 | /// The last modification date of the snaphot.
26 | var modificationDate: Date
27 |
28 | var currentIteration: SnapshotIterationIdentifier?
29 | }
30 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Disk Persistence/SortOrder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SortOrder.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-07-03.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | enum SortOrder {
10 | case ascending
11 | case equal
12 | case descending
13 |
14 | init(_ order: RangeOrder) {
15 | switch order {
16 | case .ascending: self = .ascending
17 | case .descending: self = .descending
18 | }
19 | }
20 | }
21 |
22 | extension Comparable {
23 | func sortOrder(comparedTo rhs: Self) -> SortOrder {
24 | if self < rhs { return .ascending }
25 | if self == rhs { return .equal }
26 | return .descending
27 | }
28 | }
29 |
30 | extension RangeBoundExpression {
31 | func sortOrder(comparedTo rhs: Bound, order: RangeOrder) -> SortOrder {
32 | switch (order, self) {
33 | case (.ascending, .extent): .ascending
34 | case (.ascending, .excluding(let bound)): bound < rhs ? .ascending : .descending
35 | case (.ascending, .including(let bound)): bound <= rhs ? .ascending : .descending
36 | case (.descending, .extent): .descending
37 | case (.descending, .excluding(let bound)): rhs < bound ? .descending : .ascending
38 | case (.descending, .including(let bound)): rhs <= bound ? .descending : .ascending
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Disk Persistence/StoreInfo/StoreInfo.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StoreInfo.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-07.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// Versions supported by ``DiskPersisitence``.
12 | ///
13 | /// These are used when dealing with format changes at the library level.
14 | enum StoreInfoVersion: String, Codable, Sendable {
15 | case alpha
16 | }
17 |
18 | /// A struct to store information about a ``DiskPersistence`` on disk.
19 | struct StoreInfo: Codable, Equatable, Sendable {
20 | /// The version of the persistence, used when dealing with format changes at the library level.
21 | var version: StoreInfoVersion = .alpha
22 |
23 | /// A pointer to the current snapshot.
24 | var currentSnapshot: SnapshotIdentifier?
25 |
26 | /// The last modification date of the persistence.
27 | var modificationDate: Date
28 | }
29 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Disk Persistence/Transaction/DiskCursor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DiskCursor.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-07-03.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | extension DiskPersistence {
10 | struct InstanceCursor: InstanceCursorProtocol {
11 | var persistence: DiskPersistence
12 | var datastore: Datastore
13 | var index: Datastore.Index
14 | var blocks: [CursorBlock]
15 | }
16 |
17 | struct InsertionCursor: InsertionCursorProtocol {
18 | var persistence: DiskPersistence
19 | var datastore: Datastore
20 | var index: Datastore.Index
21 |
22 | /// The location to insert a new item. If nil, it should be located in the first position of the datastore.
23 | var insertAfter: CursorBlock?
24 | }
25 |
26 | enum Cursor {
27 | case instance(InstanceCursor)
28 | case insertion(InsertionCursor)
29 | }
30 |
31 | struct CursorBlock {
32 | var pageIndex: Int
33 | var page: DiskPersistence.Datastore.Page
34 | var blockIndex: Int
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Disk Persistence/TypedIdentifier.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TypedIdentifier.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-10.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct TypedIdentifier: TypedIdentifierProtocol {
12 | var rawValue: String
13 |
14 | init(rawValue: String) {
15 | self.rawValue = rawValue
16 | }
17 | }
18 |
19 | protocol TypedIdentifierProtocol: RawRepresentable, Codable, Equatable, Hashable, CustomStringConvertible, Comparable, Sendable {
20 | var rawValue: String { get }
21 | init(rawValue: String)
22 | }
23 |
24 | extension TypedIdentifierProtocol {
25 | init(from decoder: Decoder) throws {
26 | let rawValue = try decoder.singleValueContainer().decode(String.self)
27 | self.init(rawValue: rawValue)
28 | }
29 |
30 | func encode(to encoder: Encoder) throws {
31 | var encoder = encoder.singleValueContainer()
32 | try encoder.encode(rawValue)
33 | }
34 |
35 | var description: String { rawValue }
36 | }
37 |
38 | extension TypedIdentifierProtocol {
39 | init(
40 | name: String,
41 | token: UInt64 = .random(in: UInt64.min...UInt64.max)
42 | ) {
43 | let fileSafeName = name.reduce(into: "") { partialResult, character in
44 | /// We only care about the first 16 characters
45 | guard partialResult.count < 16 else { return }
46 |
47 | /// Filter out any chatacters that could mess with filenames
48 | guard character.isASCII && (character.isLetter || character.isNumber || character == "_" || character == " ") else { return }
49 |
50 | partialResult.append(character)
51 | }
52 |
53 | let stringToken = String(token, radix: 16, uppercase: true)
54 | self.init(rawValue: "\(fileSafeName)-\(String(repeating: "0", count: 16-stringToken.count))\(stringToken)")
55 | }
56 |
57 | init(
58 | name: some RawRepresentable,
59 | token: UInt64 = .random(in: UInt64.min...UInt64.max)
60 | ) {
61 | self.init(name: name.rawValue, token: token)
62 | }
63 | }
64 |
65 | extension TypedIdentifierProtocol {
66 | static func < (lhs: Self, rhs: Self) -> Bool {
67 | lhs.rawValue < rhs.rawValue
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Memory Persistence/MemoryPersistence.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MemoryPersistence.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-03.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public actor MemoryPersistence: Persistence {
12 | public typealias AccessMode = ReadWrite
13 | }
14 |
15 | extension MemoryPersistence {
16 | public func _withTransaction(
17 | actionName: String?,
18 | options: UnsafeTransactionOptions,
19 | transaction: @Sendable (_ transaction: DatastoreInterfaceProtocol, _ isDurable: Bool) async throws -> T
20 | ) async throws -> T {
21 | preconditionFailure("Unimplemented")
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/Persistence.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Persistence.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-03.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// A persistence used to group multiple data stores into a common store.
12 | public protocol Persistence: Sendable {
13 | associatedtype AccessMode: _AccessMode
14 |
15 | /// Perform a transaction on the persistence with the specified options.
16 | /// - Parameters:
17 | /// - actionName: The name of the action to use in an undo operation. Only the names of top-level transactions are recorded. We recommend you use the Base localization name here, as you may no longer have the localized version in future versions of your app.
18 | /// - options: The options to use while building the transaction.
19 | /// - transaction: A closure representing the transaction with which to perform operations on. You should not escape the provided transaction.
20 | func _withTransaction(
21 | actionName: String?,
22 | options: UnsafeTransactionOptions,
23 | @_inheritActorContext transaction: @Sendable (_ transaction: DatastoreInterfaceProtocol, _ isDurable: Bool) async throws -> T
24 | ) async throws -> T
25 | }
26 |
27 | extension Persistence {
28 | /// Perform a set of operations as a single transaction.
29 | ///
30 | /// Within the transaction block, perform operations on multiple ``Datastore``s such that if any one of them were to fail, none will be persisted, and if all of them succeed, they will be persisted atomically.
31 | ///
32 | /// Transactions can be nested, though child transactions will only be persisted to disk once the top-most parent finishes successfully. However, a parent can wrap a child transaction in a try-catch block to recover from any errors that may have occurred.
33 | ///
34 | /// - Warning: Performing changes to a datastore that is not part of the persistence this is called on is unsupported and will result in an error.
35 | /// - Parameters:
36 | /// - actionName: The name of the action to use in an undo operation. Only the names of top-level transactions are recorded. We recommend you use the Base localization name here, as you may no longer have the localized version in future versions of your app.
37 | /// - options: A set of options to use when performing the transaction.
38 | /// - transaction: A closure with the set of operations to perform. Parameters include a reference to the persistence, and a flag indicating if the transaction is durable.
39 | /// - SeeAlso: ``Persistence/perform(actionName:options:transaction:)-1tpvd``
40 | public func perform(
41 | actionName: String? = nil,
42 | options: TransactionOptions = [],
43 | _inheritActorContext transaction: @Sendable (_ persistence: Self, _ isDurable: Bool) async throws -> T
44 | ) async throws -> T {
45 | try await _withTransaction(
46 | actionName: actionName,
47 | options: UnsafeTransactionOptions(options)
48 | ) { _, isDurable in
49 | try await transaction(self, isDurable)
50 | }
51 | }
52 |
53 | /// Perform a set of operations as a single transaction.
54 | ///
55 | /// Within the transaction block, perform operations on multiple ``Datastore``s such that if any one of them were to fail, none will be persisted, and if all of them succeed, they will be persisted atomically.
56 | ///
57 | /// Transactions can be nested, though child transactions will only be persisted to disk once the top-most parent finishes successfully. However, a parent can wrap a child transaction in a try-catch block to recover from any errors that may have occurred.
58 | ///
59 | /// - Warning: Performing changes to a datastore that is not part of the persistence this is called on is unsupported and will result in an error.
60 | /// - Parameters:
61 | /// - actionName: The name of the action to use in an undo operation. Only the names of top-level transactions are recorded.
62 | /// - options: A set of options to use when performing the transaction.
63 | /// - transaction: A closure with the set of operations to perform.
64 | /// - SeeAlso: ``Persistence/perform(actionName:options:transaction:)-5476l``
65 | public func perform(
66 | actionName: String? = nil,
67 | options: TransactionOptions = [],
68 | @_inheritActorContext transaction: @Sendable () async throws -> T
69 | ) async throws -> T {
70 | try await _withTransaction(
71 | actionName: actionName,
72 | options: UnsafeTransactionOptions(options)
73 | ) { _, isDurable in
74 | try await transaction()
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/Sources/CodableDatastore/Persistence/TransactionOptions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TransactionOptions.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-20.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | /// A set of options that the caller of a transaction can specify.
12 | public struct TransactionOptions: OptionSet, Sendable {
13 | public let rawValue: UInt64
14 |
15 | public init(rawValue: UInt64) {
16 | self.rawValue = rawValue & 0b00000111
17 | }
18 |
19 | /// The transaction is read only, and can be performed concurrently with other transactions.
20 | public static let readOnly = Self(rawValue: 1 << 0)
21 |
22 | /// The transaction can return before it has successfully written to disk, allowing subsequent writes to be queued and written all at once. If an error occurs when it is time to actually persist the changes, the state of the persistence will not reflect the changes made in a transaction.
23 | public static let collateWrites = Self(rawValue: 1 << 1)
24 |
25 | /// The transaction is idempotent and does not modify any other kind of state, and can be retried when it encounters an inconsistency. This allows a transaction to concurrently operate with other writes, which may be necessary in a disptributed environment.
26 | public static let idempotent = Self(rawValue: 1 << 2)
27 | }
28 |
29 | /// A set of options that the caller of a transaction can specify.
30 | ///
31 | /// These options are generally unsafe to use improperly, and should generally not be used.
32 | public struct UnsafeTransactionOptions: OptionSet, Sendable {
33 | public let rawValue: UInt64
34 |
35 | public init(rawValue: UInt64) {
36 | self.rawValue = rawValue
37 | }
38 |
39 | public init(_ transactionOptions: TransactionOptions) {
40 | self.rawValue = transactionOptions.rawValue
41 | }
42 |
43 | /// The transaction is read only, and can be performed concurrently with other transactions.
44 | public static let readOnly = Self(.readOnly)
45 |
46 | /// The transaction can return before it has successfully written to disk, allowing subsequent writes to be queued and written all at once. If an error occurs when it is time to actually persist the changes, the state of the persistence will not reflect the changes made in a transaction.
47 | public static let collateWrites = Self(.collateWrites)
48 |
49 | /// The transaction is idempotent and does not modify any other kind of state, and can be retried when it encounters an inconsistency. This allows a transaction to concurrently operate with other writes, which may be necessary in a disptributed environment.
50 | public static let idempotent = Self(.idempotent)
51 |
52 | /// The transaction should skip emitting observations. This is useful when the transaction must enumerate and modify the entire data set, which would cause each modified entry to be kept in memory for the duration of the transaction.
53 | public static let skipObservations = Self(rawValue: 1 << 16)
54 |
55 | /// The transaction should persist to storage even if it is a child transaction. Note that this must be the _first_ non-readonly child transaction of a parent transaction to succeed.
56 | public static let enforceDurability = Self(rawValue: 1 << 17)
57 | }
58 |
--------------------------------------------------------------------------------
/Tests/CodableDatastoreTests/DatastoreFormatTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DatastoreFormatTests.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2024-04-12.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import CodableDatastore
11 |
12 | final class DatastoreFormatTests: XCTestCase {
13 | func testDatastoreFormatAccessors() throws {
14 | struct NonCodable {}
15 |
16 | struct TestFormat: DatastoreFormat {
17 | static let defaultKey = DatastoreKey("sample")
18 | static let currentVersion = Version.a
19 |
20 | enum Version: String, CaseIterable {
21 | case a, b, c
22 | }
23 |
24 | struct Instance: Codable, Identifiable {
25 | let id: UUID
26 | var name: String
27 | var age: Int
28 | var other: [Int]
29 | // var nonCodable: NonCodable // Not allowed: Type 'TestFormat.Instance' does not conform to protocol 'Codable'
30 | var composed: String { "\(name) \(age)"}
31 | }
32 |
33 | let name = Index(\.name)
34 | let age = Index(\.age)
35 | // let other = Index(\.other) // Not allowed: Generic struct 'OneToManyIndexRepresentation' requires that '[Int]' conform to 'Comparable'
36 | let other = ManyToManyIndex(\.other)
37 | let composed = Index(\.composed)
38 | }
39 |
40 | let myValue = TestFormat.Instance(id: UUID(), name: "Hello!", age: 1, other: [2, 6])
41 |
42 | XCTAssertEqual(myValue[index: TestFormat().age], [1])
43 | XCTAssertEqual(myValue[index: TestFormat().name], ["Hello!"])
44 | XCTAssertEqual(myValue[index: TestFormat().other], [2, 6])
45 | XCTAssertEqual(myValue[index: TestFormat().composed], ["Hello! 1"])
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/Tests/CodableDatastoreTests/DatastorePageEntryTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DatastorePageEntryTests.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-07-04.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import CodableDatastore
11 | import Bytes
12 |
13 | final class DatastorePageEntryTests: XCTestCase {
14 | func testDecoding() {
15 | XCTAssertEqual(
16 | try DatastorePageEntry(
17 | bytes: """
18 |
19 |
20 | """.utf8Bytes,
21 | isPartial: false
22 | ),
23 | DatastorePageEntry(headers: [], content: [])
24 | )
25 |
26 | XCTAssertEqual(
27 | try DatastorePageEntry(
28 | bytes: """
29 |
30 | A
31 | """.utf8Bytes,
32 | isPartial: false
33 | ),
34 | DatastorePageEntry(headers: [], content: "A".utf8Bytes)
35 | )
36 |
37 | XCTAssertEqual(
38 | try DatastorePageEntry(
39 | bytes: """
40 | 1 \u{1}
41 |
42 | A
43 | """.utf8Bytes,
44 | isPartial: false
45 | ),
46 | DatastorePageEntry(headers: [[1]], content: "A".utf8Bytes)
47 | )
48 |
49 | XCTAssertEqual(
50 | try DatastorePageEntry(
51 | bytes: """
52 | 1 \u{1}
53 |
54 |
55 | """.utf8Bytes,
56 | isPartial: false
57 | ),
58 | DatastorePageEntry(headers: [[1]], content: [])
59 | )
60 |
61 | XCTAssertEqual(
62 | try DatastorePageEntry(
63 | bytes: """
64 | 1 \u{1}
65 | 2 \u{2}\u{2}
66 | 3 \u{3}\u{3}\u{3}
67 |
68 | A
69 | """.utf8Bytes,
70 | isPartial: false
71 | ),
72 | DatastorePageEntry(headers: [[1], [2, 2], [3, 3, 3]], content: "A".utf8Bytes)
73 | )
74 |
75 | XCTAssertEqual(
76 | try DatastorePageEntry(
77 | bytes: """
78 | 1 \u{1}
79 | 16 A complex
80 | string
81 | 3 \u{3}\u{3}\u{3}
82 |
83 | Some complex
84 | content
85 | """.utf8Bytes,
86 | isPartial: false
87 | ),
88 | DatastorePageEntry(headers: [[1], "A complex\nstring".utf8Bytes, [3, 3, 3]], content: "Some complex\ncontent".utf8Bytes)
89 | )
90 |
91 | XCTAssertThrowsError(
92 | try DatastorePageEntry(
93 | bytes: """
94 |
95 | """.utf8Bytes,
96 | isPartial: false
97 | )
98 | ) { error in
99 | XCTAssertEqual(error as? DiskPersistenceError, .invalidEntryFormat)
100 | }
101 |
102 | XCTAssertThrowsError(
103 | try DatastorePageEntry(
104 | bytes: """
105 | A
106 | """.utf8Bytes,
107 | isPartial: false
108 | )
109 | ) { error in
110 | XCTAssertEqual(error as? DiskPersistenceError, .invalidEntryFormat)
111 | }
112 |
113 | XCTAssertThrowsError(
114 | try DatastorePageEntry(
115 | bytes: """
116 | 1
117 | """.utf8Bytes,
118 | isPartial: false
119 | )
120 | ) { error in
121 | switch error {
122 | case BytesError.invalidMemorySize(targetSize: 1, targetType: _, actualSize: 0): break
123 | default:
124 | XCTFail("Unknown error \(error)")
125 | }
126 | }
127 |
128 | XCTAssertThrowsError(
129 | try DatastorePageEntry(
130 | bytes: """
131 | 1 1
132 | """.utf8Bytes,
133 | isPartial: false
134 | )
135 | ) { error in
136 | switch error {
137 | case BytesError.checkedSequenceNotFound: break
138 | default:
139 | XCTFail("Unknown error \(error)")
140 | }
141 | }
142 |
143 | XCTAssertThrowsError(
144 | try DatastorePageEntry(
145 | bytes: """
146 | 1\u{20}
147 |
148 |
149 | """.utf8Bytes,
150 | isPartial: false
151 | )
152 | ) { error in
153 | switch error {
154 | case BytesError.invalidMemorySize(targetSize: 1, targetType: _, actualSize: 0): break
155 | default:
156 | XCTFail("Unknown error \(error)")
157 | }
158 | }
159 |
160 | XCTAssertThrowsError(
161 | try DatastorePageEntry(
162 | bytes: """
163 | 0\u{20}
164 |
165 |
166 | """.utf8Bytes,
167 | isPartial: false
168 | )
169 | ) { error in
170 | XCTAssertEqual(error as? DiskPersistenceError, .invalidEntryFormat)
171 | }
172 |
173 | XCTAssertThrowsError(
174 | try DatastorePageEntry(
175 | bytes: """
176 | 1 1
177 |
178 | """.utf8Bytes,
179 | isPartial: false
180 | )
181 | ) { error in
182 | switch error {
183 | case BytesError.invalidMemorySize(targetSize: 1, targetType: _, actualSize: 0): break
184 | default:
185 | XCTFail("Unknown error \(error)")
186 | }
187 | }
188 | }
189 |
190 | func testEncoding() {
191 | XCTAssertEqual(
192 | DatastorePageEntry(headers: [], content: []).bytes,
193 | """
194 |
195 |
196 | """.utf8Bytes
197 | )
198 |
199 | XCTAssertEqual(
200 | DatastorePageEntry(headers: [], content: "A".utf8Bytes).bytes,
201 | """
202 |
203 | A
204 | """.utf8Bytes
205 | )
206 |
207 | XCTAssertEqual(
208 | DatastorePageEntry(headers: [[1]], content: "A".utf8Bytes).bytes,
209 | """
210 | 1 \u{1}
211 |
212 | A
213 | """.utf8Bytes
214 | )
215 |
216 | XCTAssertEqual(
217 | DatastorePageEntry(headers: [[1], [2, 2], [3, 3, 3]], content: "A".utf8Bytes).bytes,
218 | """
219 | 1 \u{1}
220 | 2 \u{2}\u{2}
221 | 3 \u{3}\u{3}\u{3}
222 |
223 | A
224 | """.utf8Bytes
225 | )
226 |
227 | XCTAssertEqual(
228 | DatastorePageEntry(headers: [[1], "A complex\nstring".utf8Bytes, [3, 3, 3]], content: "Some complex\ncontent".utf8Bytes).bytes,
229 | """
230 | 1 \u{1}
231 | 16 A complex
232 | string
233 | 3 \u{3}\u{3}\u{3}
234 |
235 | Some complex
236 | content
237 | """.utf8Bytes
238 | )
239 | }
240 |
241 | func testBlockDecomposition() {
242 | let smallEntry = DatastorePageEntry(headers: [[1]], content: [1])
243 |
244 | XCTAssertEqual(
245 | smallEntry.blocks(remainingPageSpace: 1024, maxPageSpace: 1024),
246 | [
247 | .complete(
248 | """
249 | 1 \u{1}
250 |
251 | \u{1}
252 | """.utf8Bytes
253 | )
254 | ]
255 | )
256 |
257 | XCTAssertEqual(
258 | smallEntry.blocks(remainingPageSpace: 4, maxPageSpace: 1024),
259 | [
260 | .complete(
261 | """
262 | 1 \u{1}
263 |
264 | \u{1}
265 | """.utf8Bytes
266 | )
267 | ]
268 | )
269 |
270 | XCTAssertEqual(
271 | smallEntry.blocks(remainingPageSpace: 5, maxPageSpace: 1024),
272 | [
273 | .head(
274 | """
275 | 1
276 | """.utf8Bytes
277 | ),
278 | .tail(
279 | """
280 | \u{1}
281 |
282 | \u{1}
283 | """.utf8Bytes
284 | )
285 | ]
286 | )
287 |
288 | XCTAssertEqual(
289 | smallEntry.blocks(remainingPageSpace: 6, maxPageSpace: 1024),
290 | [
291 | .head(
292 | """
293 | 1\u{20}
294 | """.utf8Bytes
295 | ),
296 | .tail(
297 | """
298 | \u{1}
299 |
300 | \u{1}
301 | """.utf8Bytes
302 | )
303 | ]
304 | )
305 |
306 | XCTAssertEqual(
307 | smallEntry.blocks(remainingPageSpace: 7, maxPageSpace: 1024),
308 | [
309 | .head(
310 | """
311 | 1 \u{1}
312 | """.utf8Bytes
313 | ),
314 | .tail(
315 | """
316 |
317 |
318 | \u{1}
319 | """.utf8Bytes
320 | )
321 | ]
322 | )
323 |
324 | XCTAssertEqual(
325 | smallEntry.blocks(remainingPageSpace: 8, maxPageSpace: 1024),
326 | [
327 | .head(
328 | """
329 | 1 \u{1}
330 |
331 | """.utf8Bytes
332 | ),
333 | .tail(
334 | """
335 |
336 | \u{1}
337 | """.utf8Bytes
338 | )
339 | ]
340 | )
341 |
342 | XCTAssertEqual(
343 | smallEntry.blocks(remainingPageSpace: 7, maxPageSpace: 7),
344 | [
345 | .head(
346 | """
347 | 1 \u{1}
348 | """.utf8Bytes
349 | ),
350 | .tail(
351 | """
352 |
353 |
354 | \u{1}
355 | """.utf8Bytes
356 | )
357 | ]
358 | )
359 |
360 | XCTAssertEqual(
361 | smallEntry.blocks(remainingPageSpace: 6, maxPageSpace: 7),
362 | [
363 | .head(
364 | """
365 | 1\u{20}
366 | """.utf8Bytes
367 | ),
368 | .slice(
369 | """
370 | \u{1}
371 |
372 |
373 | """.utf8Bytes
374 | ),
375 | .tail(
376 | """
377 | \u{1}
378 | """.utf8Bytes
379 | )
380 | ]
381 | )
382 |
383 | XCTAssertEqual(
384 | smallEntry.blocks(remainingPageSpace: 6, maxPageSpace: 6),
385 | [
386 | .head(
387 | """
388 | 1\u{20}
389 | """.utf8Bytes
390 | ),
391 | .slice(
392 | """
393 | \u{1}
394 |
395 | """.utf8Bytes
396 | ),
397 | .tail(
398 | """
399 |
400 | \u{1}
401 | """.utf8Bytes
402 | )
403 | ]
404 | )
405 |
406 | XCTAssertEqual(
407 | smallEntry.blocks(remainingPageSpace: 5, maxPageSpace: 6),
408 | [
409 | .head(
410 | """
411 | 1
412 | """.utf8Bytes
413 | ),
414 | .slice(
415 | """
416 | \u{1}
417 | """.utf8Bytes
418 | ),
419 | .slice(
420 | """
421 |
422 |
423 |
424 | """.utf8Bytes
425 | ),
426 | .tail(
427 | """
428 | \u{1}
429 | """.utf8Bytes
430 | )
431 | ]
432 | )
433 |
434 | XCTAssertEqual(
435 | smallEntry.blocks(remainingPageSpace: 5, maxPageSpace: 5),
436 | [
437 | .head(
438 | """
439 | 1
440 | """.utf8Bytes
441 | ),
442 | .slice(
443 | """
444 |
445 | """.utf8Bytes
446 | ),
447 | .slice(
448 | """
449 | \u{1}
450 | """.utf8Bytes
451 | ),
452 | .slice(
453 | """
454 |
455 |
456 | """.utf8Bytes
457 | ),
458 | .slice(
459 | """
460 |
461 |
462 | """.utf8Bytes
463 | ),
464 | .tail(
465 | """
466 | \u{1}
467 | """.utf8Bytes
468 | )
469 | ]
470 | )
471 | }
472 | }
473 |
--------------------------------------------------------------------------------
/Tests/CodableDatastoreTests/DatedIdentifierTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DatedIdentifierTests.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-10.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import CodableDatastore
11 |
12 | extension DatedIdentifier {
13 | static var mockIdentifier: Self {
14 | Self(date: Date(timeIntervalSince1970: 97445.678), token: 0x0123456789abcdef)
15 | }
16 | }
17 |
18 | final class DatedIdentifierTests: XCTestCase {
19 | func testRawValue() {
20 | XCTAssertEqual(DatedIdentifier(date: Date(timeIntervalSince1970: 0), token: 0), DatedIdentifier(rawValue: "1970-01-01 00-00-00-000 0000000000000000"))
21 | XCTAssertEqual(DatedIdentifier(rawValue: "1970-01-01 00-00-00-000 0000000000000000").rawValue, "1970-01-01 00-00-00-000 0000000000000000")
22 |
23 | XCTAssertEqual(DatedIdentifier(date: Date(timeIntervalSince1970: 0), token: 0x0123456789abcdef), DatedIdentifier(rawValue: "1970-01-01 00-00-00-000 0123456789ABCDEF"))
24 | XCTAssertEqual(DatedIdentifier(rawValue: "1970-01-01 00-00-00-000 0123456789ABCDEF").rawValue, "1970-01-01 00-00-00-000 0123456789ABCDEF")
25 |
26 | XCTAssertEqual(DatedIdentifier(rawValue: "semi-valid").rawValue, "semi-valid")
27 | }
28 |
29 | func testValidComponents() throws {
30 | let components = try DatedIdentifier.mockIdentifier.components
31 | XCTAssertEqual(components.year, "1970")
32 | XCTAssertEqual(components.month, "01")
33 | XCTAssertEqual(components.day, "02")
34 | XCTAssertEqual(components.hour, "03")
35 | XCTAssertEqual(components.minute, "04")
36 | XCTAssertEqual(components.second, "05")
37 | XCTAssertEqual(components.millisecond, "678")
38 | XCTAssertEqual(components.token, "0123456789ABCDEF")
39 | XCTAssertEqual(components.monthDay, "01-02")
40 | XCTAssertEqual(components.hourMinute, "03-04")
41 |
42 | XCTAssertEqual(DatedIdentifier(rawValue: "semi-valid").rawValue, "semi-valid")
43 | XCTAssertThrowsError(try DatedIdentifier(rawValue: "semi-valid").components) { error in
44 | XCTAssertEqual(error as? DatedIdentifierError, .invalidLength)
45 | }
46 | }
47 |
48 | func testInvalidComponents() throws {
49 | XCTAssertThrowsError(try DatedIdentifier(rawValue: "semi-valid").components) { error in
50 | XCTAssertEqual(error as? DatedIdentifierError, .invalidLength)
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/Tests/CodableDatastoreTests/DiskPersistenceTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DiskPersistenceTests.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-07.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if !canImport(Darwin)
10 | @preconcurrency import Foundation
11 | #endif
12 | import XCTest
13 | @testable import CodableDatastore
14 |
15 | final class DiskPersistenceTests: XCTestCase, @unchecked Sendable {
16 | var temporaryStoreURL: URL = FileManager.default.temporaryDirectory
17 |
18 | override func setUp() async throws {
19 | temporaryStoreURL = FileManager.default.temporaryDirectory.appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString, isDirectory: true);
20 | }
21 |
22 | override func tearDown() async throws {
23 | try? FileManager.default.removeItem(at: temporaryStoreURL)
24 | }
25 |
26 | func testTypes() throws {
27 | #if canImport(Darwin)
28 | XCTAssertTrue(type(of: try DiskPersistence.defaultStore() as Any) is DiskPersistence.Type)
29 | XCTAssertTrue(type(of: try DiskPersistence.readOnlyDefaultStore() as Any) is DiskPersistence.Type)
30 | #endif
31 | XCTAssertTrue(type(of: try DiskPersistence(readWriteURL: FileManager.default.temporaryDirectory) as Any) is DiskPersistence.Type)
32 | XCTAssertTrue(type(of: DiskPersistence(readOnlyURL: FileManager.default.temporaryDirectory) as Any) is DiskPersistence.Type)
33 | }
34 |
35 | #if !canImport(Darwin)
36 | func testDefaultURLs() async throws {
37 | do {
38 | _ = try await DiskPersistence.defaultStore().storeURL
39 | } catch {
40 | XCTAssertEqual(error as? DiskPersistenceError, DiskPersistenceError.missingBundleID)
41 | }
42 | do {
43 | _ = try await DiskPersistence.readOnlyDefaultStore().storeURL
44 | } catch {
45 | XCTAssertEqual(error as? DiskPersistenceError, DiskPersistenceError.missingBundleID)
46 | }
47 | }
48 | #else
49 | func testDefaultURLs() async throws {
50 | let defaultDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!.absoluteString.appending("com.apple.dt.xctest.tool/DefaultStore.persistencestore/")
51 |
52 | let url = try await DiskPersistence.defaultStore().storeURL
53 | XCTAssertEqual(url.absoluteString, defaultDirectory)
54 |
55 | let url2 = try await DiskPersistence.readOnlyDefaultStore().storeURL
56 | XCTAssertEqual(url2.absoluteString, defaultDirectory)
57 | }
58 | #endif
59 |
60 | func testNoFileCreatedOnInit() async throws {
61 | _ = try DiskPersistence(readWriteURL: temporaryStoreURL)
62 | XCTAssertThrowsError(try temporaryStoreURL.checkResourceIsReachable())
63 |
64 | _ = DiskPersistence(readOnlyURL: temporaryStoreURL)
65 | XCTAssertThrowsError(try temporaryStoreURL.checkResourceIsReachable())
66 | }
67 |
68 | func testThrowsWithRemoteURLs() async throws {
69 | XCTAssertThrowsError(try DiskPersistence(readWriteURL: URL(string: "https://apple.com/")!))
70 | }
71 |
72 | func testStoreCreatedWhenAsked() async throws {
73 | let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL)
74 | try await persistence.createPersistenceIfNecessary()
75 |
76 | XCTAssertTrue(try temporaryStoreURL.checkResourceIsReachable())
77 | XCTAssertTrue(try temporaryStoreURL.appendingPathComponent("Snapshots", isDirectory: true).checkResourceIsReachable())
78 | XCTAssertTrue(try temporaryStoreURL.appendingPathComponent("Backups", isDirectory: true).checkResourceIsReachable())
79 | XCTAssertTrue(try temporaryStoreURL.appendingPathComponent("Info.json", isDirectory: false).checkResourceIsReachable())
80 | }
81 |
82 | func testStoreInfoOnEmptyStore() async throws {
83 | let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL)
84 | try await persistence.createPersistenceIfNecessary()
85 |
86 | let data = try Data(contentsOf: temporaryStoreURL.appendingPathComponent("Info.json", isDirectory: false))
87 |
88 | struct TestStruct: Codable {
89 | var version: String
90 | var modificationDate: String
91 | var currentSnapshot: String?
92 | }
93 |
94 | let testStruct = try JSONDecoder().decode(TestStruct.self, from: data)
95 | XCTAssertEqual(testStruct.version, "alpha")
96 | XCTAssertNil(testStruct.currentSnapshot)
97 |
98 | let formatter = ISO8601DateFormatter()
99 | formatter.timeZone = TimeZone(secondsFromGMT: 0)
100 | formatter.formatOptions = [
101 | .withInternetDateTime,
102 | .withDashSeparatorInDate,
103 | .withColonSeparatorInTime,
104 | .withTimeZone,
105 | .withFractionalSeconds
106 | ]
107 | XCTAssertNotNil(formatter.date(from: testStruct.modificationDate))
108 | }
109 |
110 | func testStoreCreatesOnlyIfNecessary() async throws {
111 | let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL)
112 | try await persistence.createPersistenceIfNecessary()
113 | let dataBefore = try Data(contentsOf: temporaryStoreURL.appendingPathComponent("Info.json", isDirectory: false))
114 | // This second time should be a no-op and shouldn't throw
115 | try await persistence.createPersistenceIfNecessary()
116 |
117 | XCTAssertTrue(try temporaryStoreURL.checkResourceIsReachable())
118 | XCTAssertTrue(try temporaryStoreURL.appendingPathComponent("Snapshots", isDirectory: true).checkResourceIsReachable())
119 | XCTAssertTrue(try temporaryStoreURL.appendingPathComponent("Backups", isDirectory: true).checkResourceIsReachable())
120 | XCTAssertTrue(try temporaryStoreURL.appendingPathComponent("Info.json", isDirectory: false).checkResourceIsReachable())
121 |
122 | let dataAfter = try Data(contentsOf: temporaryStoreURL.appendingPathComponent("Info.json", isDirectory: false))
123 | XCTAssertEqual(dataBefore, dataAfter)
124 | }
125 |
126 | func testStoreInfoAccessOrder() async throws {
127 | let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL)
128 | try await persistence.createPersistenceIfNecessary()
129 |
130 | let date1 = Date(timeIntervalSince1970: 0)
131 | let date2 = Date(timeIntervalSince1970: 10)
132 |
133 | let task1 = await persistence.updateStoreInfo { storeInfo in
134 | sleep(1)
135 | XCTAssertNotEqual(storeInfo.modificationDate, date2)
136 | storeInfo.modificationDate = date1
137 | }
138 |
139 | let task2 = await persistence.updateStoreInfo { storeInfo in
140 | XCTAssertEqual(storeInfo.modificationDate, date1)
141 | storeInfo.modificationDate = date2
142 | }
143 |
144 | try await task1.value
145 | try await task2.value
146 |
147 | let currentStoreInfo = await persistence.cachedStoreInfo
148 | XCTAssertEqual(currentStoreInfo?.modificationDate, date2)
149 | }
150 |
151 | func testAccessingStoreInfoAlsoCreatesPersistence() async throws {
152 | let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL)
153 |
154 | try await persistence.withStoreInfo { _ in }
155 |
156 | XCTAssertTrue(try temporaryStoreURL.checkResourceIsReachable())
157 | XCTAssertTrue(try temporaryStoreURL.appendingPathComponent("Snapshots", isDirectory: true).checkResourceIsReachable())
158 | XCTAssertTrue(try temporaryStoreURL.appendingPathComponent("Backups", isDirectory: true).checkResourceIsReachable())
159 | XCTAssertTrue(try temporaryStoreURL.appendingPathComponent("Info.json", isDirectory: false).checkResourceIsReachable())
160 | }
161 |
162 | func testCallingStoreInfoResursively() async throws {
163 | let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL)
164 |
165 | try await persistence.withStoreInfo { storeInfoA in
166 | let originalStoreA = storeInfoA
167 | storeInfoA.modificationDate = Date(timeIntervalSince1970: 1)
168 | let modifiedStoreA = storeInfoA
169 | try await persistence.withStoreInfo { storeInfoB in
170 | XCTAssertEqual(originalStoreA, storeInfoB)
171 | XCTAssertNotEqual(modifiedStoreA, storeInfoB)
172 | }
173 | }
174 |
175 | try await persistence.withStoreInfo { storeInfoA in
176 | try await persistence.withStoreInfo { storeInfoB in
177 | // No change:
178 | storeInfoB.modificationDate = Date(timeIntervalSince1970: 1)
179 | }
180 | }
181 |
182 | do {
183 | try await persistence.withStoreInfo { storeInfoA in
184 | try await persistence.withStoreInfo { storeInfoB in
185 | storeInfoB.modificationDate = Date(timeIntervalSince1970: 2)
186 | }
187 | }
188 | XCTFail("Reached code that shouldn't run")
189 | } catch {
190 | XCTAssertEqual(error as? DiskPersistenceInternalError, .nestedStoreWrite)
191 | }
192 |
193 | }
194 | }
195 |
--------------------------------------------------------------------------------
/Tests/CodableDatastoreTests/DiskTransactionTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DiskTransactionTests.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-07-02.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if !canImport(Darwin)
10 | @preconcurrency import Foundation
11 | #endif
12 | import XCTest
13 | @testable import CodableDatastore
14 |
15 | final class DiskTransactionTests: XCTestCase, @unchecked Sendable {
16 | var temporaryStoreURL: URL = FileManager.default.temporaryDirectory
17 |
18 | override func setUp() async throws {
19 | temporaryStoreURL = FileManager.default.temporaryDirectory.appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString, isDirectory: true);
20 | }
21 |
22 | override func tearDown() async throws {
23 | try? FileManager.default.removeItem(at: temporaryStoreURL)
24 | }
25 |
26 | func testApplyDescriptor() async throws {
27 | let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL)
28 |
29 | struct TestFormat: DatastoreFormat {
30 | enum Version: Int, CaseIterable {
31 | case zero
32 | }
33 |
34 | struct Instance: Codable {}
35 | typealias Identifier = UUID
36 |
37 | static let defaultKey: DatastoreKey = "test"
38 | static let currentVersion = Version.zero
39 | }
40 |
41 |
42 | let datastore = Datastore(
43 | persistence: persistence,
44 | format: TestFormat.self,
45 | decoders: [.zero: { _ in (id: UUID(), instance: TestFormat.Instance()) }],
46 | configuration: .init()
47 | )
48 |
49 | let descriptor = DatastoreDescriptor(
50 | version: Data([0x00]),
51 | instanceType: "TestStruct",
52 | identifierType: "UUID",
53 | directIndexes: [:],
54 | referenceIndexes: [:],
55 | size: 0
56 | )
57 |
58 | try await persistence._withTransaction(actionName: nil, options: []) { transaction, _ in
59 | let existingDescriptor = try await transaction.register(datastore: datastore)
60 | XCTAssertNil(existingDescriptor)
61 | }
62 |
63 | try await persistence._withTransaction(actionName: nil, options: []) { transaction, _ in
64 | try await transaction.apply(descriptor: descriptor, for: "test")
65 | }
66 |
67 | try await persistence._withTransaction(actionName: nil, options: []) { transaction, _ in
68 | let existingDescriptor = try await transaction.datastoreDescriptor(for: "test")
69 | XCTAssertEqual(existingDescriptor, descriptor)
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Tests/CodableDatastoreTests/IndexRangeExpressionTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IndexRangeExpressionTests.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-05.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import CodableDatastore
11 |
12 | final class IndexRangeExpressionTests: XCTestCase {
13 | func testLowerBound() throws {
14 | func lowerBoundExpression(_ range: some IndexRangeExpression) -> RangeBoundExpression {
15 | range.lowerBoundExpression
16 | }
17 |
18 | func lowerBoundExpression(_ range: Swift.UnboundedRange) -> RangeBoundExpression {
19 | lowerBoundExpression(IndexRange())
20 | }
21 |
22 | XCTAssertEqual(lowerBoundExpression(1..<2), .including(1))
23 | XCTAssertEqual(lowerBoundExpression(1...2), .including(1))
24 | XCTAssertEqual(lowerBoundExpression(1..>2), .excluding(1))
25 | XCTAssertEqual(lowerBoundExpression(..<2), .extent)
26 | XCTAssertEqual(lowerBoundExpression(...2), .extent)
27 | XCTAssertEqual(lowerBoundExpression(1...), .including(1))
28 | XCTAssertEqual(lowerBoundExpression(1..>), .excluding(1))
29 | XCTAssertEqual(lowerBoundExpression(...), .extent)
30 | }
31 |
32 | func testUpperBound() throws {
33 | func upperBoundExpression(_ range: some IndexRangeExpression) -> RangeBoundExpression {
34 | range.upperBoundExpression
35 | }
36 |
37 | func upperBoundExpression(_ range: Swift.UnboundedRange) -> RangeBoundExpression {
38 | upperBoundExpression(IndexRange())
39 | }
40 |
41 | XCTAssertEqual(upperBoundExpression(1..<2), .excluding(2))
42 | XCTAssertEqual(upperBoundExpression(1...2), .including(2))
43 | XCTAssertEqual(upperBoundExpression(1..>2), .including(2))
44 | XCTAssertEqual(upperBoundExpression(..<2), .excluding(2))
45 | XCTAssertEqual(upperBoundExpression(...2), .including(2))
46 | XCTAssertEqual(upperBoundExpression(1...), .extent)
47 | XCTAssertEqual(upperBoundExpression(1..>), .extent)
48 | XCTAssertEqual(upperBoundExpression(...), .extent)
49 | }
50 |
51 | func testPosition() throws {
52 | func position(of value: B, in range: some IndexRangeExpression) -> RangePosition {
53 | range.position(of: value)
54 | }
55 |
56 | func position(of value: Int, in range: Swift.UnboundedRange) -> RangePosition {
57 | position(of: value, in: IndexRange())
58 | }
59 |
60 | XCTAssertEqual(position(of: 0, in: 1..<3), .before)
61 | XCTAssertEqual(position(of: 0, in: 1...3), .before)
62 | XCTAssertEqual(position(of: 0, in: 1..>3), .before)
63 | XCTAssertEqual(position(of: 0, in: ..<3), .within)
64 | XCTAssertEqual(position(of: 0, in: ...3), .within)
65 | XCTAssertEqual(position(of: 0, in: 1...), .before)
66 | XCTAssertEqual(position(of: 0, in: 1..>), .before)
67 | XCTAssertEqual(position(of: 0, in: ...), .within)
68 |
69 | XCTAssertEqual(position(of: 1, in: 1..<3), .within)
70 | XCTAssertEqual(position(of: 1, in: 1...3), .within)
71 | XCTAssertEqual(position(of: 1, in: 1..>3), .before)
72 | XCTAssertEqual(position(of: 1, in: ..<3), .within)
73 | XCTAssertEqual(position(of: 1, in: ...3), .within)
74 | XCTAssertEqual(position(of: 1, in: 1...), .within)
75 | XCTAssertEqual(position(of: 1, in: 1..>), .before)
76 | XCTAssertEqual(position(of: 1, in: ...), .within)
77 |
78 | XCTAssertEqual(position(of: 2, in: 1..<3), .within)
79 | XCTAssertEqual(position(of: 2, in: 1...3), .within)
80 | XCTAssertEqual(position(of: 2, in: 1..>3), .within)
81 | XCTAssertEqual(position(of: 2, in: ..<3), .within)
82 | XCTAssertEqual(position(of: 2, in: ...3), .within)
83 | XCTAssertEqual(position(of: 2, in: 1...), .within)
84 | XCTAssertEqual(position(of: 2, in: 1..>), .within)
85 | XCTAssertEqual(position(of: 2, in: ...), .within)
86 |
87 | XCTAssertEqual(position(of: 3, in: 1..<3), .after)
88 | XCTAssertEqual(position(of: 3, in: 1...3), .within)
89 | XCTAssertEqual(position(of: 3, in: 1..>3), .within)
90 | XCTAssertEqual(position(of: 3, in: ..<3), .after)
91 | XCTAssertEqual(position(of: 3, in: ...3), .within)
92 | XCTAssertEqual(position(of: 3, in: 1...), .within)
93 | XCTAssertEqual(position(of: 3, in: 1..>), .within)
94 | XCTAssertEqual(position(of: 3, in: ...), .within)
95 |
96 | XCTAssertEqual(position(of: 4, in: 1..<3), .after)
97 | XCTAssertEqual(position(of: 4, in: 1...3), .after)
98 | XCTAssertEqual(position(of: 4, in: 1..>3), .after)
99 | XCTAssertEqual(position(of: 4, in: ..<3), .after)
100 | XCTAssertEqual(position(of: 4, in: ...3), .after)
101 | XCTAssertEqual(position(of: 4, in: 1...), .within)
102 | XCTAssertEqual(position(of: 4, in: 1..>), .within)
103 | XCTAssertEqual(position(of: 4, in: ...), .within)
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/Tests/CodableDatastoreTests/OptionalTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OptionalTests.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2024-04-20.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import CodableDatastore
11 |
12 | final class OptionalTests: XCTestCase {
13 | func testComparable() throws {
14 | XCTAssertTrue(Int?.some(5) < Int?.some(10))
15 | XCTAssertTrue(Int?.none < Int?.some(10))
16 | XCTAssertFalse(Int?.some(5) < Int?.none)
17 | XCTAssertFalse(Int?.none < Int?.none)
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Tests/CodableDatastoreTests/SnapshotIterationTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SnapshotIterationTests.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2025-02-16.
6 | // Copyright © 2023-25 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import CodableDatastore
11 |
12 | final class SnapshotIterationTests: XCTestCase, @unchecked Sendable {
13 | func testDecodingLegacyDatastoreRootReferences() throws {
14 | let data = Data("""
15 | {
16 | "addedDatastoreRoots" : [
17 | "2025-02-12 00-00-00-046 44BBE608B9CBF788"
18 | ],
19 | "addedDatastores" : [
20 |
21 | ],
22 | "creationDate" : "2025-02-12T00:00:00.057Z",
23 | "dataStores" : {
24 | "Store" : {
25 | "id" : "Store-FD9BA6F1BD3667C8",
26 | "key" : "Store",
27 | "root" : "2024-08-24 09-39-57-775 66004A6BA331B89C"
28 | }
29 | },
30 | "id" : "2025-02-12 00-00-00-057 0130730F8F6A1ACC",
31 | "precedingIteration" : "2025-02-11 23-59-54-727 447A1A1E1CF82177",
32 | "removedDatastoreRoots" : [
33 | "2025-02-11 23-59-54-721 2AAEA12A38303055"
34 | ],
35 | "removedDatastores" : [
36 |
37 | ],
38 | "successiveIterations" : [
39 |
40 | ],
41 | "version" : "alpha"
42 | }
43 | """.utf8)
44 |
45 | let decoder = JSONDecoder()
46 | decoder.dateDecodingStrategy = .iso8601WithMilliseconds
47 | _ = try decoder.decode(SnapshotIteration.self, from: data)
48 | }
49 |
50 | func testDecodingCurrentDatastoreRootReferences() throws {
51 | let data = Data("""
52 | {
53 | "addedDatastoreRoots" : [
54 | {
55 | "datastoreID" : "Store-FD9BA6F1BD3667C8",
56 | "datastoreRootID" : "2025-02-12 00-00-00-046 44BBE608B9CBF788"
57 | }
58 | ],
59 | "addedDatastores" : [
60 |
61 | ],
62 | "creationDate" : "2025-02-12T00:00:00.057Z",
63 | "dataStores" : {
64 | "Store" : {
65 | "id" : "Store-FD9BA6F1BD3667C8",
66 | "key" : "Store",
67 | "root" : "2024-08-24 09-39-57-775 66004A6BA331B89C"
68 | }
69 | },
70 | "id" : "2025-02-12 00-00-00-057 0130730F8F6A1ACC",
71 | "precedingIteration" : "2025-02-11 23-59-54-727 447A1A1E1CF82177",
72 | "removedDatastoreRoots" : [
73 | {
74 | "datastoreID" : "Store-FD9BA6F1BD3667C8",
75 | "datastoreRootID" : "2025-02-11 23-59-54-721 2AAEA12A38303055"
76 | }
77 | ],
78 | "removedDatastores" : [
79 |
80 | ],
81 | "successiveIterations" : [
82 |
83 | ],
84 | "version" : "alpha"
85 | }
86 | """.utf8)
87 |
88 | let decoder = JSONDecoder()
89 | decoder.dateDecodingStrategy = .iso8601WithMilliseconds
90 | _ = try decoder.decode(SnapshotIteration.self, from: data)
91 | }
92 |
93 | func testDecodingOptionalPrecedingSnapshotIdentifiers() throws {
94 | let data = Data("""
95 | {
96 | "addedDatastoreRoots" : [
97 | {
98 | "datastoreID" : "Store-FD9BA6F1BD3667C8",
99 | "datastoreRootID" : "2025-02-12 00-00-00-046 44BBE608B9CBF788"
100 | }
101 | ],
102 | "addedDatastores" : [
103 |
104 | ],
105 | "creationDate" : "2025-02-12T00:00:00.057Z",
106 | "dataStores" : {
107 | "Store" : {
108 | "id" : "Store-FD9BA6F1BD3667C8",
109 | "key" : "Store",
110 | "root" : "2024-08-24 09-39-57-775 66004A6BA331B89C"
111 | }
112 | },
113 | "id" : "2025-02-12 00-00-00-057 0130730F8F6A1ACC",
114 | "precedingIteration" : "2025-02-11 23-59-54-727 447A1A1E1CF82177",
115 | "precedingSnapshot" : "2024-04-14 13-09-27-739 A1EEB1A3AF102F15",
116 | "removedDatastoreRoots" : [
117 | {
118 | "datastoreID" : "Store-FD9BA6F1BD3667C8",
119 | "datastoreRootID" : "2025-02-11 23-59-54-721 2AAEA12A38303055"
120 | }
121 | ],
122 | "removedDatastores" : [
123 |
124 | ],
125 | "successiveIterations" : [
126 |
127 | ],
128 | "version" : "alpha"
129 | }
130 | """.utf8)
131 |
132 | let decoder = JSONDecoder()
133 | decoder.dateDecodingStrategy = .iso8601WithMilliseconds
134 | _ = try decoder.decode(SnapshotIteration.self, from: data)
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/Tests/CodableDatastoreTests/SnapshotTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SnapshotTests.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-10.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | #if !canImport(Darwin)
10 | @preconcurrency import Foundation
11 | #endif
12 | import XCTest
13 | @testable import CodableDatastore
14 |
15 | final class SnapshotTests: XCTestCase, @unchecked Sendable {
16 | var temporaryStoreURL: URL = FileManager.default.temporaryDirectory
17 |
18 | override func setUp() async throws {
19 | temporaryStoreURL = FileManager.default.temporaryDirectory.appendingPathComponent(ProcessInfo.processInfo.globallyUniqueString, isDirectory: true);
20 | }
21 |
22 | override func tearDown() async throws {
23 | try? FileManager.default.removeItem(at: temporaryStoreURL)
24 | }
25 |
26 | func testSnapshotIdentifiers() throws {
27 | XCTAssertEqual(SnapshotIdentifier(date: Date(timeIntervalSince1970: 0), token: 0), SnapshotIdentifier(rawValue: "1970-01-01 00-00-00-000 0000000000000000"))
28 | XCTAssertEqual(SnapshotIdentifier(rawValue: "1970-01-01 00-00-00-000 0000000000000000").rawValue, "1970-01-01 00-00-00-000 0000000000000000")
29 |
30 | XCTAssertEqual(SnapshotIdentifier(date: Date(timeIntervalSince1970: 0), token: 0x0123456789abcdef), SnapshotIdentifier(rawValue: "1970-01-01 00-00-00-000 0123456789ABCDEF"))
31 | XCTAssertEqual(SnapshotIdentifier(rawValue: "1970-01-01 00-00-00-000 0123456789ABCDEF").rawValue, "1970-01-01 00-00-00-000 0123456789ABCDEF")
32 |
33 | let components = try SnapshotIdentifier.mockIdentifier.components
34 | XCTAssertEqual(components.year, "1970")
35 | XCTAssertEqual(components.month, "01")
36 | XCTAssertEqual(components.day, "02")
37 | XCTAssertEqual(components.hour, "03")
38 | XCTAssertEqual(components.minute, "04")
39 | XCTAssertEqual(components.second, "05")
40 | XCTAssertEqual(components.millisecond, "678")
41 | XCTAssertEqual(components.token, "0123456789ABCDEF")
42 | XCTAssertEqual(components.monthDay, "01-02")
43 | XCTAssertEqual(components.hourMinute, "03-04")
44 |
45 | XCTAssertEqual(SnapshotIdentifier(rawValue: "semi-valid").rawValue, "semi-valid")
46 | XCTAssertThrowsError(try SnapshotIdentifier(rawValue: "semi-valid").components) { error in
47 | XCTAssertEqual(error as? DatedIdentifierError, .invalidLength)
48 | }
49 | }
50 |
51 | func testNoFileCreatedOnInit() async throws {
52 | let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL)
53 | try await persistence.createPersistenceIfNecessary()
54 |
55 | let isEmpty = await persistence.snapshots.isEmpty
56 | XCTAssertTrue(isEmpty)
57 |
58 | let snapshot = Snapshot(id: SnapshotIdentifier.mockIdentifier, persistence: persistence, isBackup: false)
59 | let snapshotURL = snapshot.snapshotURL
60 | XCTAssertEqual(snapshotURL.absoluteString, temporaryStoreURL.absoluteString.appending("Snapshots/1970/01-02/03-04/1970-01-02%2003-04-05-678%200123456789ABCDEF.snapshot/"))
61 | XCTAssertThrowsError(try snapshotURL.checkResourceIsReachable())
62 | }
63 |
64 | func testManifestCreatedWhenAsked() async throws {
65 | let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL)
66 |
67 | let snapshot = Snapshot(id: SnapshotIdentifier.mockIdentifier, persistence: persistence, isBackup: false)
68 |
69 | try await snapshot.updatingManifest { _, _ in }
70 |
71 | let snapshotURL = snapshot.snapshotURL
72 | XCTAssertEqual(snapshotURL.absoluteString, temporaryStoreURL.absoluteString.appending("Snapshots/1970/01-02/03-04/1970-01-02%2003-04-05-678%200123456789ABCDEF.snapshot/"))
73 |
74 | XCTAssertTrue(try snapshotURL.checkResourceIsReachable())
75 | XCTAssertTrue(try snapshotURL.appendingPathComponent("Inbox", isDirectory: true).checkResourceIsReachable())
76 | XCTAssertTrue(try snapshotURL.appendingPathComponent("Datastores", isDirectory: true).checkResourceIsReachable())
77 | XCTAssertTrue(try snapshotURL.appendingPathComponent("Manifest.json", isDirectory: false).checkResourceIsReachable())
78 | }
79 |
80 | func testStoreNotCreatedWithManifest() async throws {
81 | let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL)
82 | let snapshot = Snapshot(id: SnapshotIdentifier.mockIdentifier, persistence: persistence, isBackup: false)
83 | try await snapshot.updatingManifest { _, _ in }
84 |
85 | XCTAssertTrue(try temporaryStoreURL.checkResourceIsReachable())
86 | XCTAssertTrue(try temporaryStoreURL.appendingPathComponent("Snapshots", isDirectory: true).checkResourceIsReachable())
87 | XCTAssertThrowsError(try temporaryStoreURL.appendingPathComponent("Backups", isDirectory: true).checkResourceIsReachable())
88 | XCTAssertThrowsError(try temporaryStoreURL.appendingPathComponent("Info.json", isDirectory: false).checkResourceIsReachable())
89 | }
90 |
91 | func testManifestOnEmptySnapshot() async throws {
92 | let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL)
93 | let snapshot = Snapshot(id: SnapshotIdentifier.mockIdentifier, persistence: persistence, isBackup: false)
94 | try await snapshot.updatingManifest { _, _ in }
95 | let snapshotURL = snapshot.snapshotURL
96 |
97 | let data = try Data(contentsOf: snapshotURL.appendingPathComponent("Manifest.json", isDirectory: false))
98 |
99 | struct TestStruct: Codable {
100 | var version: String
101 | var id: String
102 | var modificationDate: String
103 | var currentIteration: String?
104 | }
105 |
106 | let testStruct = try JSONDecoder().decode(TestStruct.self, from: data)
107 | XCTAssertEqual(testStruct.version, "alpha")
108 | XCTAssertNotNil(testStruct.currentIteration)
109 | XCTAssertEqual(testStruct.id, "1970-01-02 03-04-05-678 0123456789ABCDEF")
110 |
111 | let formatter = ISO8601DateFormatter()
112 | formatter.timeZone = TimeZone(secondsFromGMT: 0)
113 | formatter.formatOptions = [
114 | .withInternetDateTime,
115 | .withDashSeparatorInDate,
116 | .withColonSeparatorInTime,
117 | .withTimeZone,
118 | .withFractionalSeconds
119 | ]
120 | XCTAssertNotNil(formatter.date(from: testStruct.modificationDate))
121 | }
122 |
123 | func testSnapshotCreatesOnlyIfNecessary() async throws {
124 | let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL)
125 | let snapshot = Snapshot(id: SnapshotIdentifier.mockIdentifier, persistence: persistence, isBackup: false)
126 | try await snapshot.updatingManifest { _, _ in }
127 | let snapshotURL = snapshot.snapshotURL
128 |
129 | let dataBefore = try Data(contentsOf: snapshotURL.appendingPathComponent("Manifest.json", isDirectory: false))
130 | // This second time should be a no-op and shouldn't throw
131 | try await snapshot.updatingManifest { _, _ in }
132 |
133 | let dataAfter = try Data(contentsOf: snapshotURL.appendingPathComponent("Manifest.json", isDirectory: false))
134 | XCTAssertEqual(dataBefore, dataAfter)
135 | }
136 |
137 | func testManifestAccessOrder() async throws {
138 | let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL)
139 | let snapshot = Snapshot(id: SnapshotIdentifier.mockIdentifier, persistence: persistence, isBackup: false)
140 | try await snapshot.updatingManifest { _, _ in }
141 |
142 | let date1 = Date(timeIntervalSince1970: 0)
143 | let date2 = Date(timeIntervalSince1970: 10)
144 |
145 | let task1 = await snapshot.updateManifest { manifest, _ in
146 | sleep(1)
147 | XCTAssertNotEqual(manifest.modificationDate, date2)
148 | manifest.modificationDate = date1
149 | }
150 |
151 | let task2 = await snapshot.updateManifest { manifest, _ in
152 | XCTAssertEqual(manifest.modificationDate, date1)
153 | manifest.modificationDate = date2
154 | }
155 |
156 | try await task1.value
157 | try await task2.value
158 |
159 | let currentManifest = await snapshot.cachedManifest
160 | XCTAssertEqual(currentManifest?.modificationDate, date2)
161 | }
162 |
163 | func testCallingManifestResursively() async throws {
164 | let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL)
165 | let snapshot = Snapshot(id: SnapshotIdentifier.mockIdentifier, persistence: persistence, isBackup: false)
166 | try await snapshot.updatingManifest { _, _ in }
167 |
168 | try await snapshot.updatingManifest { manifestA, _ in
169 | let originalManifestA = manifestA
170 | manifestA.modificationDate = Date(timeIntervalSince1970: 1)
171 | let modifiedManifestA = manifestA
172 | try await snapshot.updatingManifest { manifestB, _ in
173 | XCTAssertEqual(originalManifestA, manifestB)
174 | XCTAssertNotEqual(modifiedManifestA, manifestB)
175 | }
176 | }
177 |
178 | try await snapshot.updatingManifest { manifestA, _ in
179 | try await snapshot.updatingManifest { manifestB, _ in
180 | // No change:
181 | manifestB.modificationDate = Date(timeIntervalSince1970: 1)
182 | }
183 | }
184 |
185 | do {
186 | try await snapshot.updatingManifest { manifestA, _ in
187 | try await snapshot.updatingManifest { manifestB, _ in
188 | manifestB.modificationDate = Date(timeIntervalSince1970: 2)
189 | }
190 | }
191 | XCTFail("Reached code that shouldn't run")
192 | } catch {
193 | XCTAssertEqual(error as? DiskPersistenceInternalError, .nestedSnapshotWrite)
194 | }
195 |
196 | }
197 | }
198 |
--------------------------------------------------------------------------------
/Tests/CodableDatastoreTests/TransactionOptionsTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TransactionOptionsTests.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-07-12.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | import CodableDatastore
11 |
12 | final class TransactionOptionsTests: XCTestCase {
13 | func assertTransactionOptions(
14 | options: TransactionOptions,
15 | expectedRawValue: UInt64,
16 | file: StaticString = #filePath,
17 | line: UInt = #line
18 | ) {
19 | XCTAssertEqual(options.rawValue, expectedRawValue, file: file, line: line)
20 | }
21 |
22 | func testTransactionOptions() {
23 | assertTransactionOptions(options: [], expectedRawValue: 0)
24 |
25 | assertTransactionOptions(options: .readOnly, expectedRawValue: 1)
26 | assertTransactionOptions(options: .collateWrites, expectedRawValue: 2)
27 | assertTransactionOptions(options: .idempotent, expectedRawValue: 4)
28 |
29 | assertTransactionOptions(options: [.readOnly, .collateWrites], expectedRawValue: 3)
30 | assertTransactionOptions(options: [.readOnly, .idempotent], expectedRawValue: 5)
31 | assertTransactionOptions(options: [.collateWrites, .idempotent], expectedRawValue: 6)
32 |
33 | assertTransactionOptions(options: [.readOnly, .collateWrites, .idempotent], expectedRawValue: 7)
34 | }
35 |
36 | func testInvalidTransactionOptions() {
37 | assertTransactionOptions(options: TransactionOptions(rawValue: 8), expectedRawValue: 0)
38 | assertTransactionOptions(options: TransactionOptions(rawValue: 9), expectedRawValue: 1)
39 | assertTransactionOptions(options: TransactionOptions(rawValue: 10), expectedRawValue: 2)
40 | assertTransactionOptions(options: TransactionOptions(rawValue: 11), expectedRawValue: 3)
41 | }
42 | }
43 |
44 | final class UnsafeTransactionOptionsTests: XCTestCase {
45 | func assertUnsafeTransactionOptions(
46 | options: UnsafeTransactionOptions,
47 | expectedRawValue: UInt64,
48 | file: StaticString = #filePath,
49 | line: UInt = #line
50 | ) {
51 | XCTAssertEqual(options.rawValue, expectedRawValue, file: file, line: line)
52 | }
53 |
54 | func testUnsafeTransactionOptions() {
55 | assertUnsafeTransactionOptions(options: [], expectedRawValue: 0)
56 |
57 | assertUnsafeTransactionOptions(options: .readOnly, expectedRawValue: 1)
58 | assertUnsafeTransactionOptions(options: .collateWrites, expectedRawValue: 2)
59 | assertUnsafeTransactionOptions(options: .idempotent, expectedRawValue: 4)
60 | assertUnsafeTransactionOptions(options: .skipObservations, expectedRawValue: 65536)
61 |
62 | assertUnsafeTransactionOptions(options: [.readOnly, .collateWrites], expectedRawValue: 3)
63 | assertUnsafeTransactionOptions(options: [.readOnly, .idempotent], expectedRawValue: 5)
64 | assertUnsafeTransactionOptions(options: [.readOnly, .skipObservations], expectedRawValue: 65537)
65 | assertUnsafeTransactionOptions(options: [.collateWrites, .idempotent], expectedRawValue: 6)
66 | assertUnsafeTransactionOptions(options: [.collateWrites, .skipObservations], expectedRawValue: 65538)
67 | assertUnsafeTransactionOptions(options: [.idempotent, .skipObservations], expectedRawValue: 65540)
68 |
69 | assertUnsafeTransactionOptions(options: [.readOnly, .collateWrites, .idempotent], expectedRawValue: 7)
70 | assertUnsafeTransactionOptions(options: [.readOnly, .collateWrites, .skipObservations], expectedRawValue: 65539)
71 | assertUnsafeTransactionOptions(options: [.readOnly, .idempotent, .skipObservations], expectedRawValue: 65541)
72 | assertUnsafeTransactionOptions(options: [.collateWrites, .idempotent, .skipObservations], expectedRawValue: 65542)
73 |
74 | assertUnsafeTransactionOptions(options: [.readOnly, .collateWrites, .idempotent, .skipObservations], expectedRawValue: 65543)
75 | }
76 |
77 | func testConvertedTransactionOptions() {
78 | assertUnsafeTransactionOptions(options: UnsafeTransactionOptions(TransactionOptions([])), expectedRawValue: 0)
79 |
80 | assertUnsafeTransactionOptions(options: UnsafeTransactionOptions(TransactionOptions.readOnly), expectedRawValue: 1)
81 | assertUnsafeTransactionOptions(options: UnsafeTransactionOptions(TransactionOptions.collateWrites), expectedRawValue: 2)
82 | assertUnsafeTransactionOptions(options: UnsafeTransactionOptions(TransactionOptions.idempotent), expectedRawValue: 4)
83 |
84 | assertUnsafeTransactionOptions(options: UnsafeTransactionOptions(TransactionOptions([.readOnly, .collateWrites])), expectedRawValue: 3)
85 | assertUnsafeTransactionOptions(options: UnsafeTransactionOptions(TransactionOptions([.readOnly, .idempotent])), expectedRawValue: 5)
86 | assertUnsafeTransactionOptions(options: UnsafeTransactionOptions(TransactionOptions([.collateWrites, .idempotent])), expectedRawValue: 6)
87 |
88 | assertUnsafeTransactionOptions(options: UnsafeTransactionOptions(TransactionOptions([.readOnly, .collateWrites, .idempotent])), expectedRawValue: 7)
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/Tests/CodableDatastoreTests/TypedIdentifierTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TypedIdentifierTests.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-10.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import CodableDatastore
11 |
12 | extension TypedIdentifier {
13 | static var mockIdentifier: Self {
14 | Self(name: "Test", token: 0x0123456789abcdef)
15 | }
16 | }
17 |
18 | final class TypedIdentifierTests: XCTestCase {
19 | func testRawValue() {
20 | XCTAssertEqual(TypedIdentifier(rawValue: "").rawValue, "")
21 | XCTAssertEqual(TypedIdentifier(rawValue: "Test-0000000000000000").rawValue, "Test-0000000000000000")
22 | XCTAssertEqual(TypedIdentifier(rawValue: "semi-valid").rawValue, "semi-valid")
23 | }
24 |
25 | func testNameToken() {
26 | XCTAssertEqual(TypedIdentifier(name: "Test", token: 0), TypedIdentifier(rawValue: "Test-0000000000000000"))
27 |
28 | XCTAssertEqual(TypedIdentifier(name: "Test", token: 0x0123456789abcdef), TypedIdentifier(rawValue: "Test-0123456789ABCDEF"))
29 |
30 | XCTAssertEqual(TypedIdentifier(name: "With Spaces", token: 0x0123456789abcdef), TypedIdentifier(rawValue: "With Spaces-0123456789ABCDEF"))
31 |
32 | XCTAssertEqual(TypedIdentifier(name: "With-Dashes", token: 0x0123456789abcdef), TypedIdentifier(rawValue: "WithDashes-0123456789ABCDEF"))
33 |
34 | XCTAssertEqual(TypedIdentifier(name: "With_Underscores", token: 0x0123456789abcdef), TypedIdentifier(rawValue: "With_Underscores-0123456789ABCDEF"))
35 |
36 | XCTAssertEqual(TypedIdentifier(name: "With Long-names_And-Characters", token: 0x0123456789abcdef), TypedIdentifier(rawValue: "With Longnames_A-0123456789ABCDEF"))
37 |
38 | XCTAssertEqual(TypedIdentifier(name: "Numbers? 0", token: 0x0123456789abcdef), TypedIdentifier(rawValue: "Numbers 0-0123456789ABCDEF"))
39 |
40 | XCTAssertEqual(TypedIdentifier(name: "", token: 0x0123456789abcdef), TypedIdentifier(rawValue: "-0123456789ABCDEF"))
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/Tests/CodableDatastoreTests/UUIDTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UUIDTests.swift
3 | // CodableDatastore
4 | //
5 | // Created by Dimitri Bouniol on 2023-06-04.
6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved.
7 | //
8 |
9 | import XCTest
10 | @testable import CodableDatastore
11 |
12 | final class UUIDTests: XCTestCase {
13 | func testComparable() throws {
14 | XCTAssertTrue(UUID(uuidString: "00112233-4455-6677-8899-AABBCCDDEEF0")! < UUID(uuidString: "00112233-4455-6677-8899-AABBCCDDEEF1")!)
15 | XCTAssertTrue(UUID(uuidString: "00112233-4455-6677-8899-AABBCCDDEEF1")! > UUID(uuidString: "00112233-4455-6677-8899-AABBCCDDEEF0")!)
16 | XCTAssertTrue(UUID(uuidString: "00112233-4455-6677-8899-AABBCCDDEEF1")! < UUID(uuidString: "10112233-4455-6677-8899-AABBCCDDEEF0")!)
17 | XCTAssertTrue(UUID(uuidString: "10112233-4455-6677-8899-AABBCCDDEEF0")! > UUID(uuidString: "00112233-4455-6677-8899-AABBCCDDEEF1")!)
18 | }
19 | }
20 |
--------------------------------------------------------------------------------