Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/CodableDatastore/Datastore/Datastore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ extension Datastore {
try await warmupIfNeeded()

return try await persistence._withTransaction(
actionName: nil,
actionName: "Check Count",
options: [.idempotent, .readOnly]
) { transaction, _ in
let descriptor = try await transaction.datastoreDescriptor(for: self.key)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,18 +138,20 @@ extension DiskPersistence {
deletedPages.removeAll()
}

/// If the transaction is read-only, stop here without applying anything to the parent.
guard !options.contains(.readOnly) else {
assert(entryMutations.isEmpty, "Entries were mutated in a read-only transaction!")
assert(createdRootObjects.isEmpty, "Root objects were created in a read-only transaction!")
assert(createdIndexes.isEmpty, "Indexes were created in a read-only transaction!")
assert(createdPages.isEmpty, "Pages were created in a read-only transaction!")
assert(deletedRootObjects.isEmpty, "Root objects were deleted in a read-only transaction!")
assert(deletedIndexes.isEmpty, "Indexes were deleted in a read-only transaction!")
assert(deletedPages.isEmpty, "Pages were deleted in a read-only transaction!")
return
}

/// If the transaction has a parent, offload responsibility to it to persist the changes along with other children.
if let parent {
/// If the transaction is read-only, stop here without applying anything to the parent.
guard !options.contains(.readOnly) else {
assert(entryMutations.isEmpty, "Entries were mutated in a read-only transaction!")
assert(createdRootObjects.isEmpty, "Root objects were created in a read-only transaction!")
assert(createdIndexes.isEmpty, "Indexes were created in a read-only transaction!")
assert(createdPages.isEmpty, "Pages were created in a read-only transaction!")
assert(deletedRootObjects.isEmpty, "Root objects were deleted in a read-only transaction!")
assert(deletedIndexes.isEmpty, "Indexes were deleted in a read-only transaction!")
assert(deletedPages.isEmpty, "Pages were deleted in a read-only transaction!")
return
}
// print("[CDS] Offloading persist to parent \(transactionIndex) - \(options)")
try await parent.apply(
rootObjects: rootObjects,
Expand Down
156 changes: 156 additions & 0 deletions Tests/CodableDatastoreTests/DiskPersistenceDatastoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,162 @@ final class DiskPersistenceDatastoreTests: XCTestCase, @unchecked Sendable {
}
}

func testReadOnlyTransactionDoesNotOverrideWrittenTransactions() async throws {
struct TestFormat: DatastoreFormat {
enum Version: Int, CaseIterable {
case zero
}

struct Instance: Codable, Identifiable {
var id: Int
var value: String
}

static let defaultKey: DatastoreKey = "test"
static let currentVersion = Version.zero
}

let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL)
try await persistence.createPersistenceIfNecessary()

let datastore = Datastore.JSONStore(
persistence: persistence,
format: TestFormat.self,
migrations: [
.zero: { data, decoder in
try decoder.decode(TestFormat.Instance.self, from: data)
}
]
)

let valueBank = [
"Hello, World!",
"My name is Dimitri",
"Writen using CodableDatastore",
"Swift is better than Objective-C, there, I said it",
"Twenty Three is Number One"
]

try await persistence.perform {
for id in 0..<10 {
try await datastore.persist(.init(id: id, value: valueBank.randomElement()!))
}
}

var totalCount = try await datastore.count
XCTAssertEqual(totalCount, 10)

let (startReadingTask, startReadingContinuation) = await Task.makeUnresolved()
let (didStartReadingTask, didStartReadingContinuation) = await Task.makeUnresolved()

let readTask = Task {
let allEntries = datastore.load(...)

didStartReadingContinuation.resume()
await startReadingTask.value

var count = 0
for try await _ in allEntries {
count += 1
}
}

await didStartReadingTask.value

try await persistence.perform {
for id in 10..<20 {
try await datastore.persist(.init(id: id, value: valueBank.randomElement()!))
}
}
totalCount = try await datastore.count
XCTAssertEqual(totalCount, 20)

startReadingContinuation.resume()
try await readTask.value

totalCount = try await datastore.count
XCTAssertEqual(totalCount, 20)
}

func testNestedReadOnlyTransactionDoesNotOverrideWrittenTransactions() async throws {
struct TestFormat: DatastoreFormat {
enum Version: Int, CaseIterable {
case zero
}

struct Instance: Codable, Identifiable {
var id: Int
var value: String
}

static let defaultKey: DatastoreKey = "test"
static let currentVersion = Version.zero
}

let persistence = try DiskPersistence(readWriteURL: temporaryStoreURL)
try await persistence.createPersistenceIfNecessary()

let datastore = Datastore.JSONStore(
persistence: persistence,
format: TestFormat.self,
migrations: [
.zero: { data, decoder in
try decoder.decode(TestFormat.Instance.self, from: data)
}
]
)

let valueBank = [
"Hello, World!",
"My name is Dimitri",
"Writen using CodableDatastore",
"Swift is better than Objective-C, there, I said it",
"Twenty Three is Number One"
]

try await persistence.perform {
for id in 0..<10 {
try await datastore.persist(.init(id: id, value: valueBank.randomElement()!))
}
}

var totalCount = try await datastore.count
XCTAssertEqual(totalCount, 10)

let (startReadingTask, startReadingContinuation) = await Task.makeUnresolved()
let (didStartReadingTask, didStartReadingContinuation) = await Task.makeUnresolved()

let readTask = Task {
try await persistence.perform(options: .readOnly) {
let allEntries = datastore.load(...)

didStartReadingContinuation.resume()
await startReadingTask.value

var count = 0
for try await _ in allEntries {
count += 1
}
}
}

await didStartReadingTask.value

try await persistence.perform {
for id in 10..<20 {
try await datastore.persist(.init(id: id, value: valueBank.randomElement()!))
}
}
totalCount = try await datastore.count
XCTAssertEqual(totalCount, 20)

startReadingContinuation.resume()
try await readTask.value

totalCount = try await datastore.count
XCTAssertEqual(totalCount, 20)
}

func testTakingSnapshots() async throws {
struct TestFormat: DatastoreFormat {
enum Version: Int, CaseIterable {
Expand Down
24 changes: 24 additions & 0 deletions Tests/CodableDatastoreTests/Task+Unresolved.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// Task+Unresolved.swift
// CodableDatastore
//
// Created by Dimitri Bouniol on 12/22/25.
// Copyright © 2023-25 Mochi Development, Inc. All rights reserved.
//

extension Task where Success == Void, Failure == Never {
static func makeUnresolved() async -> (
task: Task<Success, Failure>,
continuation: CheckedContinuation<Success, Failure>
) {
var task: Task<Success, Failure>?
let continuation = await withCheckedContinuation { factoryContinuation in
task = Task {
await withCheckedContinuation { taskContinuation in
factoryContinuation.resume(returning: taskContinuation)
}
}
}
return (task!, continuation)
}
}