├── .github └── workflows │ ├── api.yml │ └── test.yml ├── .gitignore ├── .spi.yml ├── .swiftpm └── xcode │ └── xcshareddata │ └── xcbaselines │ └── CodableDatastoreTests.xcbaseline │ ├── 8A10B1EA-398E-4822-A587-2E59AF1B7EBC.plist │ └── Info.plist ├── LICENSE ├── Package.swift ├── Package@swift-6.0.swift ├── README.md ├── Sources └── CodableDatastore │ ├── CodableDatastore.docc │ ├── CodableDatastore.md │ └── Conceptual │ │ ├── On Disk Representation.md │ │ └── Persisting History.md │ ├── Datastore │ ├── Configuration.swift │ ├── Datastore.swift │ ├── DatastoreDescriptor.swift │ ├── DatastoreError.swift │ ├── DatastoreFormat.swift │ ├── DatastoreKey.swift │ ├── Dictionary+RawRepresentable.swift │ ├── ObservedEvent.swift │ ├── Progress.swift │ ├── RawRepresentable+Codable.swift │ └── TypedAsyncSequence.swift │ ├── Debug │ └── GlobalTimer.swift │ ├── Indexes │ ├── GeneratedIndexRepresentation.swift │ ├── IndexName.swift │ ├── IndexRangeExpression.swift │ ├── IndexRepresentation.swift │ ├── IndexStorage.swift │ ├── IndexType.swift │ ├── Indexable.swift │ └── UUID+Comparable.swift │ └── Persistence │ ├── AccessMode.swift │ ├── Cursor.swift │ ├── DatastoreInterfaceError.swift │ ├── DatastoreInterfaceProtocol.swift │ ├── Disk Persistence │ ├── AsyncThrowingBackpressureStream.swift │ ├── Datastore │ │ ├── DatastoreIndex.swift │ │ ├── DatastoreIndexManifest.swift │ │ ├── DatastorePage.swift │ │ ├── DatastorePageEntry.swift │ │ ├── DatastorePageEntryBlock.swift │ │ ├── DatastoreRoot.swift │ │ ├── DatastoreRootManifest.swift │ │ └── PersistenceDatastore.swift │ ├── DatedIdentifier.swift │ ├── DiskPersistence.swift │ ├── DiskPersistenceError.swift │ ├── ISO8601DateFormatter+Milliseconds.swift │ ├── JSONCoder.swift │ ├── LazyTask.swift │ ├── Snapshot │ │ ├── Snapshot.swift │ │ ├── SnapshotIteration.swift │ │ └── SnapshotManifest.swift │ ├── SortOrder.swift │ ├── StoreInfo │ │ └── StoreInfo.swift │ ├── Transaction │ │ ├── DiskCursor.swift │ │ └── Transaction.swift │ └── TypedIdentifier.swift │ ├── Memory Persistence │ └── MemoryPersistence.swift │ ├── Persistence.swift │ └── TransactionOptions.swift └── Tests └── CodableDatastoreTests ├── DatastoreDescriptorTests.swift ├── DatastoreFormatTests.swift ├── DatastorePageEntryTests.swift ├── DatedIdentifierTests.swift ├── DiskPersistenceDatastoreIndexTests.swift ├── DiskPersistenceDatastoreTests.swift ├── DiskPersistenceTests.swift ├── DiskTransactionTests.swift ├── IndexRangeExpressionTests.swift ├── OptionalTests.swift ├── SnapshotIterationTests.swift ├── SnapshotTests.swift ├── TransactionOptionsTests.swift ├── TypedIdentifierTests.swift └── UUIDTests.swift /.github/workflows/api.yml: -------------------------------------------------------------------------------- 1 | name: API Check 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | API-Check: 10 | name: Diagnose API Breaking Changes 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 10 13 | steps: 14 | - name: Checkout Source 15 | uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | - name: Mark Workspace As Safe 19 | # https://github.com/actions/checkout/issues/766 20 | run: git config --global --add safe.directory ${GITHUB_WORKSPACE} 21 | - name: Diagnose API Breaking Changes 22 | run: | 23 | swift package diagnose-api-breaking-changes origin/main --products CodableDatastore 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test CodableDatastore 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | Test-Release: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 10 12 | steps: 13 | - name: Checkout Source 14 | uses: actions/checkout@v3 15 | - name: Build 16 | run: | 17 | swift build --build-tests --configuration release -Xswiftc -enable-testing -Xswiftc -warnings-as-errors -Xcc -Werror 18 | - name: Run Tests 19 | run: | 20 | swift test --skip-build --configuration release 21 | 22 | Test-Debug: 23 | runs-on: ubuntu-latest 24 | timeout-minutes: 10 25 | steps: 26 | - name: Checkout Source 27 | uses: actions/checkout@v3 28 | - name: Build 29 | run: | 30 | swift build --build-tests --configuration debug -Xswiftc -enable-testing -Xswiftc -warnings-as-errors -Xcc -Werror 31 | - name: Run Tests 32 | run: | 33 | swift test --skip-build --configuration debug 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | Package.resolved 11 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [CodableDatastore] 5 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcbaselines/CodableDatastoreTests.xcbaseline/8A10B1EA-398E-4822-A587-2E59AF1B7EBC.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | classNames 6 | 7 | DiskPersistenceDatastoreIndexTests 8 | 9 | testInMemorySearchSpeed() 10 | 11 | com.apple.XCTPerformanceMetric_WallClockTime 12 | 13 | baselineAverage 14 | 0.155911 15 | baselineIntegrationDisplayName 16 | Local Baseline 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcbaselines/CodableDatastoreTests.xcbaseline/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | runDestinationsByUUID 6 | 7 | 8A10B1EA-398E-4822-A587-2E59AF1B7EBC 8 | 9 | localComputer 10 | 11 | busSpeedInMHz 12 | 0 13 | cpuCount 14 | 1 15 | cpuKind 16 | Apple M1 Max 17 | cpuSpeedInMHz 18 | 0 19 | logicalCPUCoresPerPackage 20 | 10 21 | modelCode 22 | MacBookPro18,2 23 | physicalCPUCoresPerPackage 24 | 10 25 | platformIdentifier 26 | com.apple.platform.macosx 27 | 28 | targetArchitecture 29 | arm64 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mochi Development, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CodableDatastore", 8 | platforms: [ 9 | .macOS(.v10_15), 10 | .iOS(.v13), 11 | .tvOS(.v13), 12 | .watchOS(.v6), 13 | ], 14 | products: [ 15 | .library( 16 | name: "CodableDatastore", 17 | targets: ["CodableDatastore"] 18 | ), 19 | ], 20 | dependencies: [ 21 | .package(url: "https://github.com/mochidev/AsyncSequenceReader.git", .upToNextMinor(from: "0.3.1")), 22 | .package(url: "https://github.com/mochidev/Bytes.git", .upToNextMinor(from: "0.3.0")), 23 | ], 24 | targets: [ 25 | .target( 26 | name: "CodableDatastore", 27 | dependencies: [ 28 | "AsyncSequenceReader", 29 | "Bytes" 30 | ], 31 | swiftSettings: [ 32 | .enableExperimentalFeature("StrictConcurrency"), 33 | ] 34 | ), 35 | .testTarget( 36 | name: "CodableDatastoreTests", 37 | dependencies: ["CodableDatastore"], 38 | swiftSettings: [ 39 | .enableExperimentalFeature("StrictConcurrency"), 40 | ] 41 | ), 42 | ] 43 | ) 44 | -------------------------------------------------------------------------------- /Package@swift-6.0.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CodableDatastore", 8 | platforms: [ 9 | .macOS(.v10_15), 10 | .iOS(.v13), 11 | .tvOS(.v13), 12 | .watchOS(.v6), 13 | ], 14 | products: [ 15 | .library( 16 | name: "CodableDatastore", 17 | targets: ["CodableDatastore"] 18 | ), 19 | ], 20 | dependencies: [ 21 | .package(url: "https://github.com/mochidev/AsyncSequenceReader.git", .upToNextMinor(from: "0.3.1")), 22 | .package(url: "https://github.com/mochidev/Bytes.git", .upToNextMinor(from: "0.3.0")), 23 | ], 24 | targets: [ 25 | .target( 26 | name: "CodableDatastore", 27 | dependencies: [ 28 | "AsyncSequenceReader", 29 | "Bytes" 30 | ] 31 | ), 32 | .testTarget( 33 | name: "CodableDatastoreTests", 34 | dependencies: ["CodableDatastore"] 35 | ), 36 | ] 37 | ) 38 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/CodableDatastore.docc/CodableDatastore.md: -------------------------------------------------------------------------------- 1 | # ``CodableDatastore`` 2 | 3 | ``CodableDatastore`` is a collection of types that make it easy to interface 4 | with large data stores of independent types without loading the entire 5 | data store in memory. 6 | 7 | ## Conceptual Overview 8 | 9 | - 10 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/CodableDatastore.docc/Conceptual/Persisting History.md: -------------------------------------------------------------------------------- 1 | # Persisting History 2 | 3 | How ``CodableDatastore`` saves history, enabling a potentially limitless undo 4 | stack. 5 | 6 | ## Overview 7 | 8 | Due to how ``DiskPersistence`` organizes its files, it is possible for 9 | ``CodableDatastore`` to save each transaction in a historical record that 10 | can be rewound. Three different historical records are planned to be supported: 11 | 12 | - User-Created Backups 13 | - Snapshots 14 | - Multiple Roots 15 | 16 | These goals inform how data stores are persisted on disk, and dictate the 17 | design of the API needed to access them. 18 | 19 | ### User-Created Backups 20 | 21 | The user of an app can request that a backup of their data store be taken 22 | manually or regularly, and have these be displayed in a user interface for them 23 | to export or restore. 24 | 25 | Backups are created from full copies of the persisted datastores, and restores 26 | are themselves copied from backups, making sure that they don't inadvertedly 27 | change. 28 | 29 | Backups utilize file-system block sharing when available, such as on APFS, and 30 | as such should be a relatively cheap operation to complete. 31 | 32 | ### Snapshots 33 | 34 | Snapshots are similar to backups, except they represent a live view into a data 35 | store at one moment in time. Snapshots may be created by an app automatically 36 | before a migration takes place, or the user attempts to perform an irreversible 37 | change such as merging or replacing data during a sync operation. 38 | 39 | Importantly, snapshots only create copies of data when they are created, and 40 | otherwise offer a read-write view into their data store when made active. 41 | 42 | Note: Using snapshots to partition data is not recommended, as backups will 43 | only ever represent a single snapshot. 44 | 45 | ### Multiple Roots 46 | 47 | Within a snapshot, and by extention backups, is a root object that identifies 48 | the state of the data within each datastore. Because a new root object is 49 | written at the end of every top-level transaction, they form a chain that can 50 | be traversed to go back, or forward in the persistence's timeline. 51 | 52 | A persistence can be configured in a few different ways: 53 | 54 | - Support a set number of roots, such as the last 10 transactions. 55 | - Support a dynamic number of roots, such as all edits within the last 28 days. 56 | - Support a limitless number of roots, from the very first edit. 57 | - Support a limitless number of roots, allowing the user to clear the history 58 | should they choose to do so. 59 | 60 | In each scenario, we require a robust way of both representing this history, 61 | but also being able to manipulate it, such as to clear out older entries. 62 | 63 | ``CodableDatastore`` already utilises a few tricks that make this possible. 64 | Namely, changes to data always result in a new page, and as a result new index 65 | manifests, new datastore roots, and new snapshot roots. The new index manifests 66 | reference pages that both existed before, but also the new pages that got 67 | created, while the roots above them simply point to the new and old copies down 68 | the chain. 69 | 70 | In order to properly maintain history, the new roots simply need to remember 71 | which pages they are invalidating in the process of replacing them with new 72 | ones. Then, when it comes time to cleaning up the oldest root, we only need to 73 | delete the root chain it points to, along with the pages that the next newest 74 | root replaces. 75 | 76 | To make it easier to track such chains, the snapshot root should refer to the 77 | root it replaces along with its tree of data. Then, we simply need to traverse 78 | this chain to find the oldest root, along with the second oldest one that 79 | specifies pages that are safe to delete once the oldest one is removed. As long 80 | as roots are always cleaned up from oldest to newest, the next oldest root will 81 | always be ready to remove. 82 | 83 | To properly support _rewinding_ history, we should also store the pages that got 84 | introduced with each new root. Then, if we need to trim our history from the 85 | _other_ direction, such as would be the case if a user undid an action, but 86 | instead of redoing it chose to make a different edit, we could clear the edits 87 | made in that moment. That said, deleting undone history is also a configurable 88 | transaction, should the user choose to re-explore that branch outside of your 89 | app's use case. 90 | 91 | These branches will however be deleted if older history is pruned such that the 92 | oldest root that links multiple branches is itself deleted. This is made 93 | possible by forward references that are saved ontop of older roots in the 94 | process of creating the new root that replaces them. Since this edit would 95 | atomically replace a root but would not change the consistency requirements of 96 | the data that root points to, it is a safe operation to do after the fact. 97 | 98 | #### Multiple Reading Processes 99 | 100 | In order to support multiple processes that are reading a potentially live 101 | datastore, as can be the case in an extension of an app, it is always suggested 102 | to have a few transactions always present, such that if a process grabs a 103 | read-only view based on an out-of-date root, that root does not immediately 104 | vanish from under them as they read its pages. 105 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Datastore/Configuration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Configuration.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 | public struct Configuration: Sendable { 10 | /// The size of a single page of data on disk and in memory. 11 | /// 12 | /// Applications that deal with large objects may want to consider increasing this appropriately, 13 | /// but it should always be a multiple of the disk's file block size. 14 | /// 15 | /// Increasing the page size will increase the amount of memory a data store will use as pages 16 | /// must be loaded in their entirety to load and decode objects from them. 17 | public var pageSize: Int 18 | 19 | public init( 20 | pageSize: Int = 64*1024 21 | ) { 22 | self.pageSize = pageSize 23 | } 24 | 25 | static let minimumPageSize = 4*1024 26 | static let defaultPageSize = 4*1024 27 | static let maximumPageSize = 1024*1024*1024 28 | } 29 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Datastore/DatastoreDescriptor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DatastoreDescriptor.swift 3 | // CodableDatastore 4 | // 5 | // Created by Dimitri Bouniol on 2023-06-11. 6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// A description of a ``Datastore``'s requirements of a persistence. 12 | /// 13 | /// A persistence is expected to save a description and retrieve it when a connected ``Datastore`` requests it. The ``Datastore`` then uses it to compute if indexes need to be invalidated or re-built. 14 | public struct DatastoreDescriptor: Equatable, Hashable, Sendable { 15 | /// The version that was current at time of serialization. 16 | /// 17 | /// If a ``Datastore`` cannot decode this version, the datastore is presumed inaccessible, and any reads or writes will fail. 18 | public var version: Data 19 | 20 | /// The main type the ``Datastore`` serves. 21 | /// 22 | /// This type information is strictly informational — it can freely change between runs so long as the codable representations are compatible. 23 | public var instanceType: String 24 | 25 | /// The type used to identify instances in the ``Datastore``. 26 | /// 27 | /// If this type changes, the ``Datastore`` will invalidate and re-built the primary index, which is likely to be expensive for large data sets. If the type does not change, but its conformance of ``/Swift/Comparable`` does, a migration must be manually forced. 28 | public var identifierType: String 29 | 30 | /// The direct indexes the ``Datastore`` uses. 31 | /// 32 | /// Direct indexes duplicate the entire instance in their entries, which is useful for quick ranged reads. The identifier is implicitly a direct index, though other properties may also be used should they be applicable. 33 | /// 34 | /// If the index produces the same value, the identifier of the instance is implicitly used as a secondary sort parameter. 35 | public var directIndexes: [String : IndexDescriptor] 36 | 37 | /// The secondary indexes the ``Datastore`` uses. 38 | /// 39 | /// Secondary indexes store just the value being indexed, and point to the object in the primary datastore. 40 | /// 41 | /// If the index produces the same value, the identifier of the instance is implicitly used as a secondary sort parameter. 42 | public var referenceIndexes: [String : IndexDescriptor] 43 | 44 | /// The number of instances the ``Datastore`` manages. 45 | public var size: Int 46 | } 47 | 48 | extension DatastoreDescriptor { 49 | @available(*, deprecated, renamed: "instanceType", message: "Deprecated in favor of instanceType.") 50 | public var codedType: String { 51 | get { instanceType } 52 | set { instanceType = newValue } 53 | } 54 | 55 | @available(*, deprecated, renamed: "referenceIndexes", message: "Deprecated in favor of referenceIndexes.") 56 | public var secondaryIndexes: [String : IndexDescriptor] { 57 | get { referenceIndexes } 58 | set { referenceIndexes = newValue } 59 | } 60 | } 61 | 62 | extension DatastoreDescriptor: Codable { 63 | enum CodingKeys: CodingKey { 64 | case version 65 | case instanceType 66 | case codedType // Deprecated 67 | case identifierType 68 | case directIndexes 69 | case referenceIndexes 70 | case secondaryIndexes // Deprecated 71 | case size 72 | } 73 | 74 | public init(from decoder: any Decoder) throws { 75 | let container = try decoder.container(keyedBy: CodingKeys.self) 76 | self.version = try container.decode(Data.self, forKey: .version) 77 | self.instanceType = try container.decodeIfPresent(String.self, forKey: .instanceType) ?? container.decode(String.self, forKey: .codedType) 78 | self.identifierType = try container.decode(String.self, forKey: .identifierType) 79 | self.directIndexes = try container.decode([String : DatastoreDescriptor.IndexDescriptor].self, forKey: .directIndexes) 80 | self.referenceIndexes = try container.decodeIfPresent([String : DatastoreDescriptor.IndexDescriptor].self, forKey: .referenceIndexes) ?? container.decode([String : DatastoreDescriptor.IndexDescriptor].self, forKey: .secondaryIndexes) 81 | self.size = try container.decode(Int.self, forKey: .size) 82 | } 83 | 84 | public func encode(to encoder: any Encoder) throws { 85 | var container = encoder.container(keyedBy: CodingKeys.self) 86 | try container.encode(self.version, forKey: .version) 87 | try container.encode(self.instanceType, forKey: .instanceType) 88 | try container.encode(self.identifierType, forKey: .identifierType) 89 | try container.encode(self.directIndexes, forKey: .directIndexes) 90 | try container.encode(self.referenceIndexes, forKey: .referenceIndexes) 91 | try container.encode(self.size, forKey: .size) 92 | } 93 | } 94 | 95 | extension DatastoreDescriptor { 96 | /// A description of an Index used by a ``Datastore``. 97 | /// 98 | /// This information is used to determine which indexes must be invalidated or re-built, and which can be used as is. Additionally, it informs which properties must be reported along with any writes to keep existing indexes up to date. 99 | public struct IndexDescriptor: Codable, Equatable, Hashable, Comparable, Sendable { 100 | /// The version that was first used to persist an index to disk. 101 | /// 102 | /// This is used to determine if an index must be re-built purely because something about how the index changed in a way that could not be automatically determined, such as Codable conformance changing. 103 | public var version: Data 104 | 105 | /// The key this index is based on. 106 | /// 107 | /// Each index is uniquely referred to by their name. If a ``Datastore`` reports a set of Indexes with a different set of keys than was used prior, the difference between them will be calculated, and older indexes will be invalidated while new ones will be built. 108 | public var name: IndexName 109 | 110 | /// The type the index uses for ordering. 111 | /// 112 | /// The index will use the type to automatically determine if an index should be invalidated. 113 | public var type: IndexType 114 | 115 | public static func < (lhs: Self, rhs: Self) -> Bool { 116 | lhs.name < rhs.name 117 | } 118 | } 119 | } 120 | 121 | extension DatastoreDescriptor { 122 | /// Initialize a descriptor from types a ``Datastore`` deals in directly. 123 | /// 124 | /// This will use Swift reflection to infer the indexes from the conforming ``DatastoreFormat`` instance. 125 | /// 126 | /// - Parameters: 127 | /// - format:The format of the datastore as described by the caller. 128 | /// - version: The current version being used by a data store. 129 | init( 130 | format: Format, 131 | version: Format.Version 132 | ) throws { 133 | let versionData = try Data(version) 134 | 135 | var directIndexes: [String : IndexDescriptor] = [:] 136 | var referenceIndexes: [String : IndexDescriptor] = [:] 137 | 138 | for (_, generatedRepresentation) in format.generateIndexRepresentations() { 139 | let indexName = generatedRepresentation.indexName 140 | guard Format.Instance.self as? any Identifiable.Type == nil || indexName != "id" 141 | else { continue } 142 | 143 | let indexDescriptor = IndexDescriptor( 144 | version: versionData, 145 | name: indexName, 146 | type: generatedRepresentation.index.indexType 147 | ) 148 | 149 | switch generatedRepresentation.storage { 150 | case .direct: 151 | /// Make sure the reference indexes don't contain any of the direct indexes 152 | referenceIndexes.removeValue(forKey: indexName.rawValue) 153 | directIndexes[indexName.rawValue] = indexDescriptor 154 | case .reference: 155 | referenceIndexes[indexName.rawValue] = indexDescriptor 156 | } 157 | } 158 | 159 | self.init( 160 | version: versionData, 161 | instanceType: String(describing: Format.Instance.self), 162 | identifierType: String(describing: Format.Identifier.self), 163 | directIndexes: directIndexes, 164 | referenceIndexes: referenceIndexes, 165 | size: 0 166 | ) 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Datastore/DatastoreError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DatastoreError.swift 3 | // CodableDatastore 4 | // 5 | // Created by Dimitri Bouniol on 2023-06-18. 6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// A ``Datastore``-specific error. 12 | public enum DatastoreError: LocalizedError { 13 | case missingIndex 14 | 15 | /// A decoder was missing for the specified version. 16 | case missingDecoder(version: String) 17 | 18 | /// The persisted version is incompatible with the one supported by the datastore. 19 | case incompatibleVersion(version: String?) 20 | 21 | public var errorDescription: String? { 22 | switch self { 23 | case .missingIndex: 24 | return "The specified index was not properly declared on this datastore. Please double check your implementation of `DatastoreFormat.generateIndexRepresentations()`." 25 | case .missingDecoder(let version): 26 | return "The decoder for version \(version) is missing." 27 | case .incompatibleVersion(.some(let version)): 28 | return "The persisted version \(version) is newer than the one supported by the datastore." 29 | case .incompatibleVersion(.none): 30 | return "The persisted version is incompatible with the one supported by the datastore." 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Datastore/DatastoreFormat.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DatastoreFormat.swift 3 | // CodableDatastore 4 | // 5 | // Created by Dimitri Bouniol on 2024-04-07. 6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// A representation of the underlying format of a ``Datastore``. 12 | /// 13 | /// A ``DatastoreFormat`` will be instantiated and owned by the datastore associated with it to provide both type and index information to the store. It is expected to represent the ideal types for the latest version of the code that is instantiating the datastore. 14 | /// 15 | /// This type also exists so implementers can conform a `struct` to it that declares a number of key paths as stored properties. 16 | /// 17 | /// Conformers can create subtypes for their versioned models either in the body of their struct or in legacy extensions. Additionally, you are encouraged to make **static** properties available for things like the current version, or a configured ``Datastore`` — this allows easy access to them without mucking around declaring them in far-away places in your code base. 18 | /// 19 | /// ```swift 20 | /// struct BookStore: DatastoreFormat { 21 | /// static let defaultKey: DatastoreKey = "BooksStore" 22 | /// static let currentVersion: Version = .current 23 | /// 24 | /// enum Version: String { 25 | /// case v1 = "2024-04-01" 26 | /// case current = "2024-04-09" 27 | /// } 28 | /// 29 | /// typealias Instance = Book 30 | /// 31 | /// struct BookV1: Codable, Identifiable { 32 | /// var id: UUID 33 | /// var title: String 34 | /// var author: String 35 | /// } 36 | /// 37 | /// struct Book: Codable, Identifiable { 38 | /// var id: UUID 39 | /// var title: SortableTitle 40 | /// var authors: [AuthorID] 41 | /// var isbn: ISBN 42 | /// } 43 | /// 44 | /// static func datastore(for persistence: DiskPersistence) -> Self.Datastore { 45 | /// .JSONStore( 46 | /// persistence: persistence, 47 | /// migrations: [ 48 | /// .v1: { data, decoder in try Book(decoder.decode(BookV1.self, from: data)) }, 49 | /// .current: { data, decoder in try decoder.decode(Book.self, from: data) } 50 | /// ] 51 | /// ) 52 | /// } 53 | /// 54 | /// let title = Index(\.title) 55 | /// let author = ManyToManyIndex(\.authors) 56 | /// @Direct var isbn = OneToOneIndex(\.isbn) 57 | /// } 58 | /// 59 | /// typealias Book = BookStore.Book 60 | /// 61 | /// extension Book { 62 | /// init(_ bookV1: BookStore.BookV1) { 63 | /// self.init( 64 | /// id: id, 65 | /// title: SortableTitle(title), 66 | /// authors: [AuthorID(authors)], 67 | /// isbn: ISBN.generate() 68 | /// ) 69 | /// } 70 | /// } 71 | /// ``` 72 | /// 73 | /// - Note: If your ``Instance`` type is ``/Swift/Identifiable``, you should _not_ declare an index for `id` — special accessors are created on your behalf that can be used instead. 74 | /// 75 | /// - Important: We discourage declaring non-static stored and computed properties on your conforming type, as that will polute the key-path namespace of the format which is used for generating getters on the datastore. 76 | public protocol DatastoreFormat: Sendable { 77 | /// A type representing the version of the datastore on disk. 78 | /// 79 | /// Best represented as an enum, this represents the every single version of the datastore you wish to be able to decode from disk. Assign a new version any time the codable representation or the representation of indexes is no longer backwards compatible. 80 | /// 81 | /// The various ``Datastore`` initializers take a disctionary that maps between these versions and the most up-to-date Instance type, and will provide an opportunity to use legacy representations to decode the data to the expected type. 82 | associatedtype Version: RawRepresentable & Hashable & CaseIterable & Sendable where Version.RawValue: Indexable & Comparable 83 | 84 | /// The most up-to-date representation you use in your codebase. 85 | associatedtype Instance: Codable & Sendable 86 | 87 | /// The identifier to be used when de-duplicating instances saved in the persistence. 88 | /// 89 | /// Although ``Instance`` does _not_ need to be ``Identifiable``, a consistent identifier must still be provided for every instance to retrive and persist them. This identifier can be different from `Instance.ID` if truly necessary, though most conformers can simply set it to `Instance.ID` 90 | associatedtype Identifier: Indexable & DiscreteIndexable & Sendable 91 | 92 | /// A default initializer creating a format instance the datastore can use for evaluation. 93 | init() 94 | 95 | /// The default key to use when accessing the datastore for this type. 96 | static var defaultKey: DatastoreKey { get } 97 | 98 | /// The current version to normalize the persisted datastore to. 99 | static var currentVersion: Version { get } 100 | 101 | /// A One-value to Many-instance index. 102 | /// 103 | /// This type of index is the most common, where multiple instances can share the same single value that is passed in. 104 | typealias Index = OneToManyIndexRepresentation 105 | 106 | /// A One-value to One-instance index. 107 | /// 108 | /// This type of index is typically used for most unique identifiers, and may be useful if there is an alternative unique identifier a instance may be referenced under. 109 | typealias OneToOneIndex = OneToOneIndexRepresentation 110 | 111 | /// A Many-value to One-instance index. 112 | /// 113 | /// This type of index can be used if several alternative identifiers can reference an instance, and they all reside in a single property. 114 | typealias ManyToManyIndex, Value: Indexable> = ManyToManyIndexRepresentation 115 | 116 | /// A Many-value to Many-instance index. 117 | /// 118 | /// This type of index is common when building relationships between different instances, where one instance may be related to several others in some way. 119 | typealias ManyToOneIndex, Value: Indexable & DiscreteIndexable> = ManyToOneIndexRepresentation 120 | 121 | /// Generate index representations for the datastore. 122 | /// 123 | /// The default implementation will create an entry for each member of the conforming type that is an ``IndexRepresentation`` type. If two members represent the _same_ type, only the one with the name that sorts _earliest_ will be used. Only stored members will be evaluated — computed members will be skipped. 124 | /// 125 | /// It is recommended that these results should be cached rather than re-generated every time. 126 | /// 127 | /// - Important: It is up to the implementer to ensure that no two _indexes_ refer to the same index name. Doing so is a mistake and will result in undefined behavior not guaranteed by the library, likely indexes being invalidated on different runs of your app. 128 | /// 129 | /// - Parameter assertIdentifiable: A flag to throw an assert if an `id` field was found when it would otherwise be a mistake. 130 | /// - Returns: A mapping between unique indexes and their usable metadata. 131 | func generateIndexRepresentations(assertIdentifiable: Bool) -> [AnyIndexRepresentation : GeneratedIndexRepresentation] 132 | } 133 | 134 | extension DatastoreFormat { 135 | public func generateIndexRepresentations(assertIdentifiable: Bool = false) -> [AnyIndexRepresentation : GeneratedIndexRepresentation] { 136 | let mirror = Mirror(reflecting: self) 137 | var results: [AnyIndexRepresentation : GeneratedIndexRepresentation] = [:] 138 | 139 | for child in mirror.children { 140 | guard 141 | let generatedIndex = generateIndexRepresentation(child: child, assertIdentifiable: assertIdentifiable) 142 | else { continue } 143 | 144 | let key = AnyIndexRepresentation(indexRepresentation: generatedIndex.index) 145 | /// If two indexes share a name, use the one that sorts earlier. 146 | if let oldIndex = results[key], oldIndex.indexName < generatedIndex.indexName { continue } 147 | 148 | /// Otherwise replace it with the current index. 149 | results[key] = generatedIndex 150 | } 151 | 152 | return results 153 | } 154 | 155 | /// Generate an index representation for a given mirror's child, or return nil if no valid index was found. 156 | /// - Parameters: 157 | /// - child: The child to introspect. 158 | /// - assertIdentifiable: A flag to throw an assert if an `id` field was found when it would otherwise be a mistake. 159 | /// - Returns: The generated index representation, or nil if one could not be found. 160 | public func generateIndexRepresentation( 161 | child: Mirror.Child, 162 | assertIdentifiable: Bool = false 163 | ) -> GeneratedIndexRepresentation? { 164 | guard let label = child.label else { return nil } 165 | 166 | let storage: IndexStorage 167 | let index: any IndexRepresentation 168 | if let erasedIndexRepresentation = child.value as? any DirectIndexRepresentation, 169 | let matchingIndex = erasedIndexRepresentation.index(matching: Instance.self) { 170 | index = matchingIndex 171 | storage = .direct 172 | } else if let erasedIndexRepresentation = child.value as? any IndexRepresentation, 173 | let matchingIndex = erasedIndexRepresentation.matches(Instance.self) { 174 | index = matchingIndex 175 | storage = .reference 176 | } else { 177 | return nil 178 | } 179 | 180 | let indexName = if label.prefix(1) == "_" { 181 | IndexName("\(label.dropFirst())") 182 | } else { 183 | IndexName(label) 184 | } 185 | 186 | /// If the type is identifiable, skip the `id` index as we always make one based on `id` 187 | if indexName == "id", Instance.self as? any Identifiable.Type != nil { 188 | if assertIdentifiable { 189 | assertionFailure("\(String(describing: Self.self)) declared `id` as an index, when the conformance is automatic since \(String(describing: Instance.self)) is Identifiable and \(String(describing: Self.self)).ID matches \(String(describing: Identifier.self)). Please remove the `id` member from the format.") 190 | } 191 | return nil 192 | } 193 | 194 | return GeneratedIndexRepresentation( 195 | indexName: indexName, 196 | index: index, 197 | storage: storage 198 | ) 199 | } 200 | } 201 | 202 | extension DatastoreFormat { 203 | /// A typealias of the read-write datastore this format describes. 204 | public typealias Datastore = CodableDatastore.Datastore 205 | 206 | /// A typealias of the read-only datastore this format describes. 207 | public typealias ReadOnlyDatastore = CodableDatastore.Datastore 208 | } 209 | 210 | //extension DatastoreFormat where Instance: Identifiable, Instance.ID: Indexable & DiscreteIndexable, Self.Identifier == Instance.ID { 211 | // @available(*, unavailable, message: "id is reserved on Identifiable Instance types.") 212 | // var id: Never { preconditionFailure("id is reserved on Identifiable Instance types.") } 213 | //} 214 | 215 | extension DatastoreFormat where Instance: Identifiable, Instance.ID: Indexable & DiscreteIndexable { 216 | public typealias Identifier = Instance.ID 217 | } 218 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Datastore/DatastoreKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DatastoreKey.swift 3 | // CodableDatastore 4 | // 5 | // Created by Dimitri Bouniol on 2023-07-01. 6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved. 7 | // 8 | 9 | public struct DatastoreKey: RawRepresentable, Hashable, Comparable, Sendable { 10 | public var rawValue: String 11 | 12 | public init(rawValue: String) { 13 | self.rawValue = rawValue 14 | } 15 | 16 | public init(_ rawValue: String) { 17 | self.rawValue = rawValue 18 | } 19 | 20 | public static func < (lhs: Self, rhs: Self) -> Bool { 21 | lhs.rawValue < rhs.rawValue 22 | } 23 | } 24 | 25 | extension DatastoreKey: ExpressibleByStringLiteral { 26 | public init(stringLiteral value: String) { 27 | self.init(rawValue: value) 28 | } 29 | } 30 | 31 | extension DatastoreKey: Decodable { 32 | public init(from decoder: Decoder) throws { 33 | let container = try decoder.singleValueContainer() 34 | self.init(rawValue: try container.decode(String.self)) 35 | } 36 | } 37 | 38 | extension DatastoreKey: Encodable { 39 | public func encode(to encoder: Encoder) throws { 40 | var container = encoder.singleValueContainer() 41 | try container.encode(rawValue) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Datastore/Dictionary+RawRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Dictionary+RawRepresentable.swift 3 | // CodableDatastore 4 | // 5 | // Created by Dimitri Bouniol on 2023-07-20. 6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Dictionary { 12 | @usableFromInline 13 | subscript(key: some RawRepresentable) -> Value? { 14 | get { 15 | return self[key.rawValue] 16 | } 17 | set(newValue) { 18 | self[key.rawValue] = newValue 19 | } 20 | _modify { 21 | defer { _fixLifetime(self) } 22 | yield &self[key.rawValue] 23 | } 24 | } 25 | 26 | @discardableResult 27 | @usableFromInline 28 | mutating func removeValue(forKey key: some RawRepresentable) -> Value? { 29 | removeValue(forKey: key.rawValue) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Datastore/ObservedEvent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObservedEvent.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 Foundation 10 | 11 | public enum ObservedEvent { 12 | case created(id: IdentifierType, newEntry: Entry) 13 | case updated(id: IdentifierType, oldEntry: Entry, newEntry: Entry) 14 | case deleted(id: IdentifierType, oldEntry: Entry) 15 | 16 | public var id: IdentifierType { 17 | switch self { 18 | case .created(let id, _), .updated(let id, _, _), .deleted(let id, _): 19 | return id 20 | } 21 | } 22 | 23 | func with(id: ID) -> ObservedEvent { 24 | switch self { 25 | case .created(_, let newEntry): 26 | return .created(id: id, newEntry: newEntry) 27 | case .updated(_, let oldEntry, let newEntry): 28 | return .updated(id: id, oldEntry: oldEntry, newEntry: newEntry) 29 | case .deleted(_, let oldEntry): 30 | return .deleted(id: id, oldEntry: oldEntry) 31 | } 32 | } 33 | 34 | func mapEntries(transform: (_ entry: Entry) async throws -> T) async rethrows -> ObservedEvent { 35 | switch self { 36 | case .created(let id, let newEntry): 37 | return try await .created(id: id, newEntry: transform(newEntry)) 38 | case .updated(let id, let oldEntry, let newEntry): 39 | return try await .updated(id: id, oldEntry: transform(oldEntry), newEntry: transform(newEntry)) 40 | case .deleted(let id, let oldEntry): 41 | return try await .deleted(id: id, oldEntry: transform(oldEntry)) 42 | } 43 | } 44 | } 45 | 46 | extension ObservedEvent: Identifiable where IdentifierType: Hashable {} 47 | extension ObservedEvent: Sendable where IdentifierType: Sendable, Entry: Sendable {} 48 | 49 | public struct ObservationEntry: Sendable { 50 | var versionData: Data 51 | var instanceData: Data 52 | } 53 | 54 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Datastore/Progress.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Progress.swift 3 | // CodableDatastore 4 | // 5 | // Created by Dimitri Bouniol on 2023-06-15. 6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public typealias ProgressHandler = @Sendable (_ progress: Progress) -> Void 12 | 13 | public enum Progress: Sendable { 14 | case evaluating 15 | case working(current: Int, total: Int) 16 | case complete(total: Int) 17 | 18 | func adding(current: Int, total: Int) -> Self { 19 | switch self { 20 | case .evaluating: return .evaluating 21 | case .working(let oldCurrent, let oldTotal): 22 | return .working(current: oldCurrent + current, total: oldTotal + total) 23 | case .complete(let oldTotal): 24 | if current == total { 25 | return .complete(total: oldTotal + total) 26 | } else { 27 | return .working(current: oldTotal + current, total: oldTotal + total) 28 | } 29 | } 30 | } 31 | 32 | func adding(_ other: Progress) -> Self { 33 | switch other { 34 | case .evaluating: return .evaluating 35 | case .working(let newCurrent, let newTotal): 36 | return adding(current: newCurrent, total: newTotal) 37 | case .complete(let newTotal): 38 | return adding(current: newTotal, total: newTotal) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Datastore/RawRepresentable+Codable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RawRepresentable+Codable.swift 3 | // CodableDatastore 4 | // 5 | // Created by Dimitri Bouniol on 2023-06-15. 6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension RawRepresentable where RawValue: Codable { 12 | init(_ data: Data) throws { 13 | let rawValue = try JSONDecoder.shared.decode(RawValue.self, from: data) 14 | guard let instance = Self(rawValue: rawValue) else { 15 | throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: [], debugDescription: "Raw value could not be used to initialize \(String(describing: Self.self))")) 16 | } 17 | self = instance 18 | } 19 | } 20 | 21 | extension Data { 22 | init(_ rawRepresentable: T) throws where T.RawValue: Codable { 23 | self = try JSONEncoder.shared.encode(rawRepresentable.rawValue) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Datastore/TypedAsyncSequence.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypedAsyncSequence.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 | public protocol TypedAsyncSequence: AsyncSequence {} 10 | 11 | // MARK: - Standard Library Conformances 12 | 13 | extension AsyncMapSequence: TypedAsyncSequence {} 14 | extension AsyncThrowingMapSequence: TypedAsyncSequence {} 15 | extension AsyncCompactMapSequence: TypedAsyncSequence {} 16 | extension AsyncThrowingCompactMapSequence: TypedAsyncSequence {} 17 | extension AsyncFilterSequence: TypedAsyncSequence {} 18 | extension AsyncThrowingFilterSequence: TypedAsyncSequence {} 19 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Debug/GlobalTimer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GlobalTimer.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 | import Foundation 10 | 11 | actor GlobalTimer { 12 | var totalTime: TimeInterval = 0 13 | var totalSamples: Int = 0 14 | static let global = GlobalTimer() 15 | 16 | func submit(time: TimeInterval, sampleRate: Int = 100) { 17 | precondition(sampleRate > 0) 18 | totalTime += time 19 | totalSamples += 1 20 | 21 | if totalSamples % sampleRate == 0 { 22 | print(" Time: \((100000*totalTime).rounded()/100)ms for \(sampleRate) samples") 23 | totalTime = 0 24 | totalSamples = 0 25 | } 26 | } 27 | 28 | static func run(sampleRate: Int = 100, task: @Sendable () async throws -> T) async rethrows -> T { 29 | let time = ProcessInfo.processInfo.systemUptime 30 | do { 31 | let result = try await task() 32 | await Self.global.submit(time: ProcessInfo.processInfo.systemUptime - time, sampleRate: sampleRate) 33 | return result 34 | } catch { 35 | await Self.global.submit(time: ProcessInfo.processInfo.systemUptime - time, sampleRate: sampleRate) 36 | throw error 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Indexes/GeneratedIndexRepresentation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeneratedIndexRepresentation.swift 3 | // CodableDatastore 4 | // 5 | // Created by Dimitri Bouniol on 2024-04-10. 6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved. 7 | // 8 | 9 | /// A helper type for passing around metadata about an index. 10 | public struct GeneratedIndexRepresentation: Sendable { 11 | /// The name the index should be serialized under. 12 | public var indexName: IndexName 13 | 14 | /// The index itself, which can be queried accordingly. 15 | public var index: any IndexRepresentation 16 | 17 | /// If the index is direct or referential in nature. 18 | public var storage: IndexStorage 19 | 20 | /// Initialize a new generated index representation. 21 | public init(indexName: IndexName, index: any IndexRepresentation, storage: IndexStorage) { 22 | self.indexName = indexName 23 | self.index = index 24 | self.storage = storage 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Indexes/IndexName.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndexName.swift 3 | // CodableDatastore 4 | // 5 | // Created by Dimitri Bouniol on 2023-07-20. 6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved. 7 | // 8 | 9 | /// A typed name that an index is keyed under. This is typically the path component of the key path that leads to an index. 10 | public struct IndexName: RawRepresentable, Hashable, Comparable, Sendable { 11 | public var rawValue: String 12 | 13 | public init(rawValue: String) { 14 | self.rawValue = rawValue 15 | } 16 | 17 | public init(_ rawValue: String) { 18 | self.rawValue = rawValue 19 | } 20 | 21 | public static func < (lhs: Self, rhs: Self) -> Bool { 22 | lhs.rawValue < rhs.rawValue 23 | } 24 | } 25 | 26 | extension IndexName: ExpressibleByStringLiteral { 27 | public init(stringLiteral value: String) { 28 | self.init(rawValue: value) 29 | } 30 | } 31 | 32 | extension IndexName: Decodable { 33 | public init(from decoder: Decoder) throws { 34 | let container = try decoder.singleValueContainer() 35 | self.init(rawValue: try container.decode(String.self)) 36 | } 37 | } 38 | 39 | extension IndexName: Encodable { 40 | public func encode(to encoder: Encoder) throws { 41 | var container = encoder.singleValueContainer() 42 | try container.encode(rawValue) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Indexes/IndexRangeExpression.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndexRangeExpression.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 | /// A type of bound found on either end of a range. 10 | public enum RangeBoundExpression: Equatable { 11 | /// The bound reaches to the extent of the set the range is within. 12 | case extent 13 | 14 | /// The bound reaches up to, but not including the given value. 15 | case excluding(Bound) 16 | 17 | /// The bound reaches through the given value. 18 | case including(Bound) 19 | } 20 | 21 | extension RangeBoundExpression: Sendable where Bound: Sendable { } 22 | 23 | /// The order a range is declared in. 24 | public enum RangeOrder: Equatable, Sendable { 25 | /// The range is in ascending order. 26 | case ascending 27 | 28 | /// The range is in descending order. 29 | case descending 30 | 31 | var reversed: Self { 32 | switch self { 33 | case .ascending: return .descending 34 | case .descending: return .ascending 35 | } 36 | } 37 | } 38 | 39 | /// A type that can represent a range within an index. 40 | public protocol IndexRangeExpression { 41 | associatedtype Bound: Comparable 42 | 43 | /// The definition of the lower bound of the range. 44 | var lowerBoundExpression: RangeBoundExpression { get } 45 | 46 | /// The definition of the upper bound of the range. 47 | var upperBoundExpression: RangeBoundExpression { get } 48 | 49 | ///The order the elements in the range appear. 50 | var order: RangeOrder { get } 51 | } 52 | 53 | extension IndexRangeExpression { 54 | /// Reverse a range so it is iterated on in the opposite direction. 55 | var reversed: IndexRange { 56 | IndexRange( 57 | lower: lowerBoundExpression, 58 | upper: upperBoundExpression, 59 | order: order.reversed 60 | ) 61 | } 62 | 63 | func applying(_ newOrder: RangeOrder) -> IndexRange { 64 | IndexRange( 65 | lower: lowerBoundExpression, 66 | upper: upperBoundExpression, 67 | order: newOrder == .ascending ? order : order.reversed 68 | ) 69 | } 70 | 71 | static var unbounded: IndexRange { 72 | IndexRange() 73 | } 74 | } 75 | 76 | /// The position relative to a range. 77 | public enum RangePosition: Equatable, Sendable { 78 | /// A value appears before the range. 79 | case before 80 | 81 | /// A value appears within the range. 82 | case within 83 | 84 | /// A value appears after the range. 85 | case after 86 | } 87 | 88 | extension IndexRangeExpression { 89 | /// The position of a value relative to the range. 90 | func position(of value: Bound) -> RangePosition { 91 | switch lowerBoundExpression { 92 | case .extent: break 93 | case .excluding(let lowerBound): 94 | if value <= lowerBound { return .before } 95 | case .including(let lowerBound): 96 | if value < lowerBound { return .before } 97 | } 98 | 99 | switch upperBoundExpression { 100 | case .extent: break 101 | case .excluding(let upperBound): 102 | if value >= upperBound { return .after } 103 | case .including(let upperBound): 104 | if value > upperBound { return .after } 105 | } 106 | 107 | return .within 108 | } 109 | } 110 | 111 | extension Range: IndexRangeExpression { 112 | @inlinable 113 | public var lowerBoundExpression: RangeBoundExpression { .including(lowerBound) } 114 | @inlinable 115 | public var upperBoundExpression: RangeBoundExpression { .excluding(upperBound) } 116 | @inlinable 117 | public var order: RangeOrder { .ascending } 118 | } 119 | 120 | extension ClosedRange: IndexRangeExpression { 121 | @inlinable 122 | public var lowerBoundExpression: RangeBoundExpression { .including(lowerBound) } 123 | @inlinable 124 | public var upperBoundExpression: RangeBoundExpression { .including(upperBound) } 125 | @inlinable 126 | public var order: RangeOrder { .ascending } 127 | } 128 | 129 | extension PartialRangeUpTo: IndexRangeExpression { 130 | @inlinable 131 | public var lowerBoundExpression: RangeBoundExpression { .extent } 132 | @inlinable 133 | public var upperBoundExpression: RangeBoundExpression { .excluding(upperBound) } 134 | @inlinable 135 | public var order: RangeOrder { .ascending } 136 | } 137 | 138 | extension PartialRangeThrough: IndexRangeExpression { 139 | @inlinable 140 | public var lowerBoundExpression: RangeBoundExpression { .extent } 141 | @inlinable 142 | public var upperBoundExpression: RangeBoundExpression { .including(upperBound) } 143 | @inlinable 144 | public var order: RangeOrder { .ascending } 145 | } 146 | 147 | extension PartialRangeFrom: IndexRangeExpression { 148 | @inlinable 149 | public var lowerBoundExpression: RangeBoundExpression { .including(lowerBound) } 150 | @inlinable 151 | public var upperBoundExpression: RangeBoundExpression { .extent } 152 | @inlinable 153 | public var order: RangeOrder { .ascending } 154 | } 155 | 156 | /// A range of indices within an Index to fetch. 157 | public struct IndexRange: IndexRangeExpression { 158 | /// The lower bound of the range. 159 | /// 160 | /// This must compare less than upper bound. 161 | public var lowerBoundExpression: RangeBoundExpression 162 | 163 | /// The upper bound of the range. 164 | /// 165 | /// This must compare greater than the lower bound. 166 | public var upperBoundExpression: RangeBoundExpression 167 | 168 | /// The order of the range to check. 169 | public var order: RangeOrder 170 | 171 | /// Construct a range of indices within an Index to fetch. 172 | /// 173 | /// The lower bound must compare less than the upper bound, though a descending range can be specified with an order 174 | public init( 175 | lower lowerBoundExpression: RangeBoundExpression = .extent, 176 | upper upperBoundExpression: RangeBoundExpression = .extent, 177 | order: RangeOrder = .ascending 178 | ) { 179 | self.lowerBoundExpression = lowerBoundExpression 180 | self.upperBoundExpression = upperBoundExpression 181 | self.order = order 182 | } 183 | 184 | /// An index range spanning a single value. 185 | public init(only value: Bound) { 186 | self.lowerBoundExpression = .including(value) 187 | self.upperBoundExpression = .including(value) 188 | self.order = .ascending 189 | } 190 | } 191 | 192 | extension IndexRange: Sendable where Bound: Sendable {} 193 | 194 | extension IndexRange where Bound == Never { 195 | static let unbounded = IndexRange() 196 | } 197 | 198 | infix operator ..> 199 | postfix operator ..> 200 | 201 | extension Comparable { 202 | /// A range excluding the lower bound. 203 | @inlinable 204 | public static func ..> (minimum: Self, maximum: Self) -> IndexRange { 205 | precondition(minimum == minimum, "Range cannot have an unordered lower bound.") 206 | precondition(maximum == maximum, "Range cannot have an unordered upper bound.") 207 | precondition(minimum <= maximum, "Range lower bound must be less than upper bound.") 208 | return IndexRange( 209 | lower: .excluding(minimum), 210 | upper: .including(maximum) 211 | ) 212 | } 213 | 214 | /// A partial range excluding the lower bound. 215 | @inlinable 216 | public static postfix func ..> (minimum: Self) -> IndexRange { 217 | precondition(minimum == minimum, "Range cannot have an unordered lower bound.") 218 | return IndexRange( 219 | lower: .excluding(minimum), 220 | upper: .extent 221 | ) 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Indexes/IndexRepresentation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndexRepresentation.swift 3 | // CodableDatastore 4 | // 5 | // Created by Dimitri Bouniol on 2024-04-07. 6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// A representation of an index for a given instance, with value information erased. 12 | /// 13 | /// - Note: Although conforming to this type will construct an index, you don't be able to access this index using any of the usuall accessors. Instead, consider confirming to ``RetrievableIndexRepresentation`` or ``SingleInstanceIndexRepresentation`` as appropriate. 14 | public protocol IndexRepresentation: Hashable, Sendable { 15 | /// The instance the index belongs to. 16 | associatedtype Instance: Sendable 17 | 18 | /// The index type seriealized to the datastore to detect changes to index structure. 19 | var indexType: IndexType { get } 20 | 21 | /// Conditionally cast the index to one that matched the instance type T. 22 | /// - Parameter instance: The instance type that we would like to verify. 23 | /// - Returns: The casted index. 24 | func matches(_ instance: T.Type) -> (any IndexRepresentation)? 25 | 26 | /// The type erased values the index matches against for a given index. 27 | func valuesToIndex(for instance: Instance) -> [AnyIndexable] 28 | } 29 | 30 | extension IndexRepresentation { 31 | /// The index representation in a form suitable for keying in a dictionary. 32 | public var key: AnyIndexRepresentation { AnyIndexRepresentation(indexRepresentation: self) } 33 | 34 | /// Check if two ``IndexRepresentation``s are equal. 35 | @usableFromInline 36 | func isEqual(rhs: some IndexRepresentation) -> Bool { 37 | return self == rhs as? Self 38 | } 39 | } 40 | 41 | /// A representation of an index for a given instance, preserving value information. 42 | public protocol RetrievableIndexRepresentation: IndexRepresentation { 43 | /// The value represented within the index. 44 | associatedtype Value: Indexable & Hashable 45 | 46 | /// The concrete values the index matches against for a given index. 47 | func valuesToIndex(for instance: Instance) -> Set 48 | } 49 | 50 | extension RetrievableIndexRepresentation { 51 | @inlinable 52 | public func valuesToIndex(for instance: Instance) -> [AnyIndexable] { 53 | valuesToIndex(for: instance).map { AnyIndexable($0)} 54 | } 55 | } 56 | 57 | /// A representation of an index for a given instance, where a single instance matches every provided value. 58 | public protocol SingleInstanceIndexRepresentation< 59 | Instance, 60 | Value 61 | >: RetrievableIndexRepresentation where Value: DiscreteIndexable {} 62 | 63 | /// A representation of an index for a given instance, where multiple index values could point to one or more instances. 64 | public protocol MultipleInputIndexRepresentation< 65 | Instance, 66 | Sequence, 67 | Value 68 | >: RetrievableIndexRepresentation { 69 | /// The sequence of values represented in the index. 70 | associatedtype Sequence: Swift.Sequence & Sendable 71 | } 72 | 73 | /// An index where every value matches at most a single instance. 74 | /// 75 | /// This type of index is typically used for most unique identifiers, and may be useful if there is an alternative unique identifier a instance may be referenced under. 76 | public struct OneToOneIndexRepresentation< 77 | Instance: Sendable, 78 | Value: Indexable & DiscreteIndexable 79 | >: SingleInstanceIndexRepresentation { 80 | @usableFromInline 81 | let keypath: KeyPath 82 | 83 | /// Initialize a One-value to One-instance index. 84 | @inlinable 85 | public init(_ keypath: KeyPath) { 86 | self.keypath = keypath 87 | } 88 | 89 | @inlinable 90 | public var indexType: IndexType { 91 | IndexType("OneToOneIndex(\(String(describing: Value.self)))") 92 | } 93 | 94 | public func matches(_ instance: T.Type) -> (any IndexRepresentation)? { 95 | guard let copy = self as? OneToOneIndexRepresentation 96 | else { return nil } 97 | return copy 98 | } 99 | 100 | @inlinable 101 | public func valuesToIndex(for instance: Instance) -> Set { 102 | [instance[keyPath: keypath as KeyPath]] 103 | } 104 | } 105 | 106 | /// An index where every value can match any number of instances, but every instance is represented by a single value. 107 | /// 108 | /// This type of index is the most common, where multiple instances can share the same single value that is passed in. 109 | public struct OneToManyIndexRepresentation< 110 | Instance: Sendable, 111 | Value: Indexable 112 | >: RetrievableIndexRepresentation { 113 | @usableFromInline 114 | let keypath: KeyPath 115 | 116 | /// Initialize a One-value to Many-instance index. 117 | @inlinable 118 | public init(_ keypath: KeyPath) { 119 | self.keypath = keypath 120 | } 121 | 122 | @inlinable 123 | public var indexType: IndexType { 124 | IndexType("OneToManyIndex(\(String(describing: Value.self)))") 125 | } 126 | 127 | @inlinable 128 | public func matches(_ instance: T.Type) -> (any IndexRepresentation)? { 129 | guard let copy = self as? OneToManyIndexRepresentation 130 | else { return nil } 131 | return copy 132 | } 133 | 134 | @inlinable 135 | public func valuesToIndex(for instance: Instance) -> Set { 136 | [instance[keyPath: keypath as KeyPath]] 137 | } 138 | } 139 | 140 | /// An index where every value matches at most a single instance., but every instance can be represented by more than a single value. 141 | /// 142 | /// This type of index can be used if several alternative identifiers can reference an instance, and they all reside in a single property. 143 | public struct ManyToOneIndexRepresentation< 144 | Instance: Sendable, 145 | Sequence: Swift.Sequence & Sendable, 146 | Value: Indexable & DiscreteIndexable 147 | >: SingleInstanceIndexRepresentation & MultipleInputIndexRepresentation { 148 | @usableFromInline 149 | let keypath: KeyPath 150 | 151 | /// Initialize a Many-value to One-instance index. 152 | @inlinable 153 | public init(_ keypath: KeyPath) { 154 | self.keypath = keypath 155 | } 156 | 157 | @inlinable 158 | public var indexType: IndexType { 159 | IndexType("ManyToOneIndex(\(String(describing: Value.self)))") 160 | } 161 | 162 | @inlinable 163 | public func matches(_ instance: T.Type) -> (any IndexRepresentation)? { 164 | guard let copy = self as? ManyToOneIndexRepresentation 165 | else { return nil } 166 | return copy 167 | } 168 | 169 | @inlinable 170 | public func valuesToIndex(for instance: Instance) -> Set { 171 | Set(instance[keyPath: keypath as KeyPath]) 172 | } 173 | } 174 | 175 | /// An index where every value can match any number of instances, and every instance can be represented by more than a single value. 176 | /// 177 | /// This type of index is common when building relationships between different instances, where one instance may be related to several others in some way. 178 | public struct ManyToManyIndexRepresentation< 179 | Instance: Sendable, 180 | Sequence: Swift.Sequence & Sendable, 181 | Value: Indexable 182 | >: MultipleInputIndexRepresentation { 183 | @usableFromInline 184 | let keypath: KeyPath 185 | 186 | /// Initialize a Many-value to Many-instance index. 187 | @inlinable 188 | public init(_ keypath: KeyPath) { 189 | self.keypath = keypath 190 | } 191 | 192 | @inlinable 193 | public var indexType: IndexType { 194 | IndexType("ManyToManyIndex(\(String(describing: Value.self)))") 195 | } 196 | 197 | @inlinable 198 | public func matches(_ instance: T.Type) -> (any IndexRepresentation)? { 199 | guard let copy = self as? ManyToManyIndexRepresentation 200 | else { return nil } 201 | return copy 202 | } 203 | 204 | @inlinable 205 | public func valuesToIndex(for instance: Instance) -> Set { 206 | Set(instance[keyPath: keypath as KeyPath]) 207 | } 208 | } 209 | 210 | /// A property wrapper for marking which indexes should store instances in their entirety without needing to do a secondary lookup. 211 | /// 212 | /// - Note: Direct indexes are best used when reads are a #1 priority and disk space is not a concern, as each direct index duplicates the etirety of the data stored in the datastore to prioritize faster reads. 213 | /// 214 | /// - Important: Do not include an index for `id` if your type is Identifiable — one is created automatically on your behalf. 215 | @propertyWrapper 216 | public struct Direct: Sendable { 217 | /// The underlying value that the index will be based off of. 218 | /// 219 | /// This is ordinarily handled transparently when used as a property wrapper. 220 | public let wrappedValue: Index 221 | 222 | /// Initialize a ``Direct`` index with an initial ``IndexRepresentation`` value. 223 | /// 224 | /// This is ordinarily handled transparently when used as a property wrapper. 225 | @inlinable 226 | public init(wrappedValue: Index) { 227 | self.wrappedValue = wrappedValue 228 | } 229 | } 230 | 231 | /// An internal helper protocol for detecting direct indexes when reflecting the format for compatible properties. 232 | protocol DirectIndexRepresentation { 233 | associatedtype Instance 234 | 235 | /// The underlying index being wrapped, conditionally casted if the instance types match. 236 | /// - Parameter instance: The instance type to cast to. 237 | /// - Returns: The casted index 238 | func index(matching instance: T.Type) -> (any IndexRepresentation)? 239 | } 240 | 241 | extension Direct: DirectIndexRepresentation { 242 | typealias Instance = Index.Instance 243 | 244 | func index(matching instance: T.Type) -> (any IndexRepresentation)? { 245 | guard let index = wrappedValue.matches(instance) else { return nil } 246 | return index 247 | } 248 | } 249 | 250 | extension Encodable { 251 | /// Retrieve the type erased values for a given index. 252 | subscript>(index indexRepresentation: Index) -> [AnyIndexable] { 253 | return indexRepresentation.valuesToIndex(for: self) 254 | } 255 | 256 | /// Retrieve the concrete values for a given index. 257 | subscript, Value>(index indexRepresentation: Index) -> Set { 258 | return indexRepresentation.valuesToIndex(for: self) 259 | } 260 | } 261 | 262 | /// A type erased index representation to be used for keying indexes in a dictionary. 263 | public struct AnyIndexRepresentation: Hashable, Sendable { 264 | @usableFromInline 265 | var indexRepresentation: any IndexRepresentation 266 | 267 | @inlinable 268 | public static func == (lhs: AnyIndexRepresentation, rhs: AnyIndexRepresentation) -> Bool { 269 | return lhs.indexRepresentation.isEqual(rhs: rhs.indexRepresentation) 270 | } 271 | 272 | @inlinable 273 | public func hash(into hasher: inout Hasher) { 274 | hasher.combine(indexRepresentation) 275 | } 276 | } 277 | 278 | /// Forced KeyPath conformance since Swift 5.10 doesn't support it out of the box. 279 | #if compiler(>=6) 280 | extension KeyPath: @unchecked @retroactive Sendable where Root: Sendable, Value: Sendable {} 281 | #else 282 | extension KeyPath: @unchecked Sendable where Root: Sendable, Value: Sendable {} 283 | #endif 284 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Indexes/IndexStorage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndexStorage.swift 3 | // CodableDatastore 4 | // 5 | // Created by Dimitri Bouniol on 2024-04-10. 6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved. 7 | // 8 | 9 | /// Indicates how instances are stored in the presistence. 10 | public enum IndexStorage: Sendable { 11 | /// Instances are stored in the index directly, requiring no further reads to access them. 12 | case direct 13 | 14 | /// Instances are only references in the index, and must be fetched in the principle index to complete a read. 15 | case reference 16 | } 17 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Indexes/IndexType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndexType.swift 3 | // CodableDatastore 4 | // 5 | // Created by Dimitri Bouniol on 2023-07-20. 6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved. 7 | // 8 | 9 | /// A typed name that an index is keyed under. This is typically the path component of the key path that leads to an index. 10 | public struct IndexType: RawRepresentable, Hashable, Comparable, Sendable { 11 | public var rawValue: String 12 | 13 | public init(rawValue: String) { 14 | self.rawValue = rawValue 15 | } 16 | 17 | public init(_ rawValue: String) { 18 | self.rawValue = rawValue 19 | } 20 | 21 | public init(_ type: T.Type) { 22 | self.rawValue = String(describing: type) 23 | } 24 | 25 | public static func < (lhs: Self, rhs: Self) -> Bool { 26 | lhs.rawValue < rhs.rawValue 27 | } 28 | } 29 | 30 | extension IndexType: ExpressibleByStringLiteral { 31 | public init(stringLiteral value: String) { 32 | self.init(rawValue: value) 33 | } 34 | } 35 | 36 | extension IndexType: Decodable { 37 | public init(from decoder: Decoder) throws { 38 | let container = try decoder.singleValueContainer() 39 | self.init(rawValue: try container.decode(String.self)) 40 | } 41 | } 42 | 43 | extension IndexType: Encodable { 44 | public func encode(to encoder: Encoder) throws { 45 | var container = encoder.singleValueContainer() 46 | try container.encode(rawValue) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Indexes/Indexable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Indexable.swift 3 | // CodableDatastore 4 | // 5 | // Created by Dimitri Bouniol on 2024-04-07. 6 | // Copyright © 2023-24 Mochi Development, Inc. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// An alias representing the requirements for a property to be indexable, namely that they conform to both ``/Swift/Codable`` and ``/Swift/Comparable``. 12 | public typealias Indexable = Comparable & Hashable & Codable & Sendable 13 | 14 | /// A type-erased container for Indexable values 15 | public struct AnyIndexable { 16 | /// The original indexable value. 17 | public var indexed: any Indexable 18 | 19 | /// Initialize a type-erased indexable value. Access it again with ``indexed``. 20 | public init(_ indexable: some Indexable) { 21 | indexed = indexable 22 | } 23 | } 24 | 25 | #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) 26 | /// Matching implementation from https://github.com/apple/swift/pull/64899/files 27 | extension Never: Codable { 28 | public init(from decoder: any Decoder) throws { 29 | let context = DecodingError.Context( 30 | codingPath: decoder.codingPath, 31 | debugDescription: "Unable to decode an instance of Never.") 32 | throw DecodingError.typeMismatch(Never.self, context) 33 | } 34 | public func encode(to encoder: any Encoder) throws {} 35 | } 36 | #endif 37 | 38 | /// A marker protocol for types that can be used as a ranged index. 39 | /// 40 | /// Ranged indexes are usually used for continuous values, where it is more desirable to retrieve intances who's indexed values lie between two other values. 41 | /// 42 | /// - Note: If an existing type is not marked as ``RangedIndexable``, but it is advantageous for your use case for it to be marked as such and satisfies the main requirements (such as retriving a range of ordered UUIDs), simply conform that type as needed: 43 | /// ```swift 44 | /// extension UUID: RangedIndexable {} 45 | /// ``` 46 | public protocol RangedIndexable: Comparable & Hashable & Codable & Sendable {} 47 | 48 | /// A marker protocol for types that can be used as a discrete index. 49 | /// 50 | /// Discrete indexes are usually used for specific values, where it is more desirable to retrieve intances who's indexed values match another value exactly. 51 | /// 52 | /// - Note: If an existing type is not marked as ``DiscreteIndexable``, but it is advantageous for your use case for it to be marked as such and satisfies the main requirements (such as retriving a specific float value), simply conform that type as needed: 53 | /// ```swift 54 | /// extension Double: DiscreteIndexable {} 55 | /// ``` 56 | public protocol DiscreteIndexable: Hashable & Codable & Sendable {} 57 | 58 | // MARK: - Swift Standard Library Conformances 59 | 60 | extension Bool: DiscreteIndexable {} 61 | extension Double: RangedIndexable {} 62 | @available(macOS 13.0, iOS 16, tvOS 16, watchOS 9, *) 63 | extension Duration: RangedIndexable {} 64 | extension Float: RangedIndexable {} 65 | #if arch(arm64) 66 | @available(macOS 11.0, iOS 14, tvOS 14, watchOS 7, *) 67 | extension Float16: RangedIndexable {} 68 | #endif 69 | extension Int: DiscreteIndexable, RangedIndexable {} 70 | extension Int8: DiscreteIndexable, RangedIndexable {} 71 | extension Int16: DiscreteIndexable, RangedIndexable {} 72 | extension Int32: DiscreteIndexable, RangedIndexable {} 73 | extension Int64: DiscreteIndexable, RangedIndexable {} 74 | extension Never: DiscreteIndexable, RangedIndexable {} 75 | extension String: DiscreteIndexable, RangedIndexable {} 76 | extension UInt: DiscreteIndexable, RangedIndexable {} 77 | extension UInt8: DiscreteIndexable, RangedIndexable {} 78 | extension UInt16: DiscreteIndexable, RangedIndexable {} 79 | extension UInt32: DiscreteIndexable, RangedIndexable {} 80 | extension UInt64: DiscreteIndexable, RangedIndexable {} 81 | 82 | #if compiler(>=6) 83 | extension Optional: @retroactive Comparable where Wrapped: Comparable { 84 | public static func < (lhs: Self, rhs: Self) -> Bool { 85 | if let lhs, let rhs { return lhs < rhs } 86 | return lhs == nil && rhs != nil 87 | } 88 | } 89 | #else 90 | extension Optional: Comparable where Wrapped: Comparable { 91 | public static func < (lhs: Self, rhs: Self) -> Bool { 92 | if let lhs, let rhs { return lhs < rhs } 93 | return lhs == nil && rhs != nil 94 | } 95 | } 96 | #endif 97 | extension Optional: DiscreteIndexable where Wrapped: DiscreteIndexable {} 98 | extension Optional: RangedIndexable where Wrapped: RangedIndexable {} 99 | 100 | // MARK: - Foundation Conformances 101 | 102 | extension Date: RangedIndexable {} 103 | #if canImport(Darwin) 104 | extension Decimal: RangedIndexable {} 105 | #else 106 | extension Decimal: RangedIndexable, @unchecked Sendable {} 107 | #endif 108 | extension UUID: DiscreteIndexable {} 109 | -------------------------------------------------------------------------------- /Sources/CodableDatastore/Indexes/UUID+Comparable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UUID+Comparable.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 Foundation 10 | 11 | /// Make UUIDs comparable on platforms that shipped without it, so that they can be used transparently as an index. 12 | #if !canImport(FoundationEssentials) 13 | #if swift(<5.9) || os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(Linux) || os(Windows) 14 | #if compiler(>=6) 15 | extension UUID: @retroactive Comparable { 16 | @inlinable 17 | @_disfavoredOverload 18 | public static func < (lhs: UUID, rhs: UUID) -> Bool { 19 | lhs.uuid < rhs.uuid 20 | } 21 | } 22 | #else 23 | extension UUID: Comparable { 24 | @inlinable 25 | @_disfavoredOverload 26 | public static func < (lhs: UUID, rhs: UUID) -> Bool { 27 | lhs.uuid < rhs.uuid 28 | } 29 | } 30 | #endif 31 | #endif 32 | #endif 33 | 34 | /// Make UUIDs comparable, so that they can be used transparently as an index. 35 | /// 36 | /// - SeeAlso: https://github.com/apple/swift-foundation/blob/5388acf1d929865d4df97d3c50e4d08bc4c6bdf0/Sources/FoundationEssentials/UUID.swift#L135-L156 37 | @inlinable 38 | public func < (lhs: uuid_t, rhs: uuid_t) -> Bool { 39 | var lhs = lhs 40 | var rhs = rhs 41 | var result: Int = 0 42 | var diff: Int = 0 43 | withUnsafeBytes(of: &lhs) { leftPtr in 44 | withUnsafeBytes(of: &rhs) { rightPtr in 45 | for offset in (0 ..< MemoryLayout.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 | --------------------------------------------------------------------------------