Skip to content

Commit 2c53188

Browse files
Added StoreInfo to refer to the store as a whole on disk
1 parent 9e15e42 commit 2c53188

File tree

5 files changed

+152
-2
lines changed

5 files changed

+152
-2
lines changed

Sources/CodableDatastore/CodableDatastore.docc/On Disk Representation.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ saved to disk, located at the user's chosen ``/Foundation/URL``.
1212
The persistence store is the top-most level a ``DiskPersistence`` uses, and
1313
contains a list of [snapshots](#Snapshots) and [backups](#Backups), a pointer
1414
to the most recent snapshot, and some basic metadata such as version and last
15-
modification date in `Manifest.json`.
15+
modification date in `Info.json`.
1616

1717
The file layout is as follows:
1818

1919
```
2020
- 📦 Path/To/Data.persistencestore/
21-
- 📃 Manifest.json
21+
- 📃 Info.json
2222
- 📁 Snapshots/
2323
- 📁 Backups/
2424
```

Sources/CodableDatastore/Persistence/Disk Persistence/DiskPersistence.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ public actor DiskPersistence<AccessMode: _AccessMode>: Persistence {
3535
/// The location of this persistence.
3636
let storeURL: URL
3737

38+
var cachedStoreInfo: StoreInfo?
39+
3840
/// Initialize a ``DiskPersistence`` with a read-write URL.
3941
///
4042
/// Use this initializer when creating a persistence from the main process that will access it, such as your app. To access the same persistence from another process, use ``init(readOnlyURL:)`` instead.
@@ -118,6 +120,41 @@ extension DiskPersistence {
118120
var backupsURL: URL {
119121
storeURL.appendingPathComponent("Backups", isDirectory: true)
120122
}
123+
124+
var storeInfoURL: URL {
125+
storeURL.appendingPathComponent("Info.json", isDirectory: false)
126+
}
127+
}
128+
129+
extension DiskPersistence {
130+
/// Load the store info from disk, or create a suitable starting value if such a file does not exist.
131+
func loadStoreInfo() throws -> StoreInfo {
132+
do {
133+
let data = try Data(contentsOf: storeInfoURL)
134+
135+
let storeInfoDecoder = JSONDecoder()
136+
storeInfoDecoder.dateDecodingStrategy = .iso8601WithMilliseconds
137+
let storeInfo = try storeInfoDecoder.decode(StoreInfo.self, from: data)
138+
139+
cachedStoreInfo = storeInfo
140+
return storeInfo
141+
} catch URLError.fileDoesNotExist, CocoaError.fileReadNoSuchFile {
142+
return StoreInfo(modificationDate: Date())
143+
} catch let error as NSError where error.domain == NSPOSIXErrorDomain && error.code == Int(POSIXError.ENOENT.rawValue) {
144+
return StoreInfo(modificationDate: Date())
145+
} catch {
146+
throw error
147+
}
148+
}
149+
150+
/// Write the specified store info to the store, and cache the results in ``DiskPersistence/cachedStoreInfo``.
151+
func write(_ storeInfo: StoreInfo) throws {
152+
let storeInfoEncoder = JSONEncoder()
153+
storeInfoEncoder.dateEncodingStrategy = .iso8601WithMilliseconds
154+
let data = try storeInfoEncoder.encode(storeInfo)
155+
try data.write(to: storeInfoURL, options: .atomic)
156+
cachedStoreInfo = storeInfo
157+
}
121158
}
122159

123160
extension DiskPersistence where AccessMode == ReadWrite {
@@ -129,6 +166,13 @@ extension DiskPersistence where AccessMode == ReadWrite {
129166
try FileManager.default.createDirectory(at: storeURL, withIntermediateDirectories: true)
130167
try FileManager.default.createDirectory(at: snapshotsURL, withIntermediateDirectories: true)
131168
try FileManager.default.createDirectory(at: backupsURL, withIntermediateDirectories: true)
169+
170+
// Load the store info, so we can see if we'll need to write it or not.
171+
let storeInfo = try loadStoreInfo()
172+
// If the cached store info is nil, we didn't have one already, so write the one we got back to disk.
173+
if (cachedStoreInfo == nil) {
174+
try write(storeInfo)
175+
}
132176
}
133177
}
134178

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//
2+
// ISO8601DateFormatter+Milliseconds.swift
3+
// CodableDatastore
4+
//
5+
// Created by Dimitri Bouniol on 2023-06-07.
6+
// Copyright © 2023 Mochi Development, Inc. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
extension ISO8601DateFormatter {
12+
static var withMilliseconds: ISO8601DateFormatter = {
13+
let formatter = ISO8601DateFormatter()
14+
formatter.timeZone = TimeZone(secondsFromGMT: 0)
15+
formatter.formatOptions = [
16+
.withInternetDateTime,
17+
.withDashSeparatorInDate,
18+
.withColonSeparatorInTime,
19+
.withTimeZone,
20+
.withFractionalSeconds
21+
]
22+
return formatter
23+
}()
24+
}
25+
26+
extension JSONDecoder.DateDecodingStrategy {
27+
static let iso8601WithMilliseconds: Self = custom { decoder in
28+
let container = try decoder.singleValueContainer()
29+
let string = try container.decode(String.self)
30+
guard let date = ISO8601DateFormatter.withMilliseconds.date(from: string) else {
31+
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(string)")
32+
}
33+
return date
34+
35+
}
36+
}
37+
38+
extension JSONEncoder.DateEncodingStrategy {
39+
static let iso8601WithMilliseconds: Self = custom { date, encoder in
40+
let string = ISO8601DateFormatter.withMilliseconds.string(from: date)
41+
var container = encoder.singleValueContainer()
42+
try container.encode(string)
43+
}
44+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
//
2+
// StoreInfo.swift
3+
// CodableDatastore
4+
//
5+
// Created by Dimitri Bouniol on 2023-06-07.
6+
// Copyright © 2023 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 {
15+
case alpha
16+
}
17+
18+
/// A struct to store information about a ``DiskPersistence`` on disk.
19+
struct StoreInfo: Codable, Equatable {
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: String?
25+
26+
/// The last modification date of the persistence.
27+
var modificationDate: Date
28+
}

Tests/CodableDatastoreTests/DiskPersistenceTests.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,16 +73,50 @@ final class DiskPersistenceTests: XCTestCase {
7373
XCTAssertTrue(try temporaryStoreURL.checkResourceIsReachable())
7474
XCTAssertTrue(try temporaryStoreURL.appendingPathComponent("Snapshots", isDirectory: true).checkResourceIsReachable())
7575
XCTAssertTrue(try temporaryStoreURL.appendingPathComponent("Backups", isDirectory: true).checkResourceIsReachable())
76+
XCTAssertTrue(try temporaryStoreURL.appendingPathComponent("Info.json", isDirectory: false).checkResourceIsReachable())
77+
}
78+
79+
func testStoreInfoOnEmptyStore() async throws {
80+
let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL)
81+
try await persistence.createPersistenceIfNecessary()
82+
83+
let data = try Data(contentsOf: temporaryStoreURL.appendingPathComponent("Info.json", isDirectory: false))
84+
85+
struct TestStruct: Codable {
86+
var version: String
87+
var modificationDate: String
88+
var currentSnapshot: String?
89+
}
90+
91+
let testStruct = try JSONDecoder().decode(TestStruct.self, from: data)
92+
XCTAssertEqual(testStruct.version, "alpha")
93+
XCTAssertNil(testStruct.currentSnapshot)
94+
95+
let formatter = ISO8601DateFormatter()
96+
formatter.timeZone = TimeZone(secondsFromGMT: 0)
97+
formatter.formatOptions = [
98+
.withInternetDateTime,
99+
.withDashSeparatorInDate,
100+
.withColonSeparatorInTime,
101+
.withTimeZone,
102+
.withFractionalSeconds
103+
]
104+
XCTAssertNotNil(formatter.date(from: testStruct.modificationDate))
76105
}
77106

78107
func testStoreCreatesOnlyIfNecessary() async throws {
79108
let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL)
80109
try await persistence.createPersistenceIfNecessary()
110+
let dataBefore = try Data(contentsOf: temporaryStoreURL.appendingPathComponent("Info.json", isDirectory: false))
81111
// This second time should be a no-op and shouldn't throw
82112
try await persistence.createPersistenceIfNecessary()
83113

84114
XCTAssertTrue(try temporaryStoreURL.checkResourceIsReachable())
85115
XCTAssertTrue(try temporaryStoreURL.appendingPathComponent("Snapshots", isDirectory: true).checkResourceIsReachable())
86116
XCTAssertTrue(try temporaryStoreURL.appendingPathComponent("Backups", isDirectory: true).checkResourceIsReachable())
117+
XCTAssertTrue(try temporaryStoreURL.appendingPathComponent("Info.json", isDirectory: false).checkResourceIsReachable())
118+
119+
let dataAfter = try Data(contentsOf: temporaryStoreURL.appendingPathComponent("Info.json", isDirectory: false))
120+
XCTAssertEqual(dataBefore, dataAfter)
87121
}
88122
}

0 commit comments

Comments
 (0)