SwiftData JSONDataStore with relationships

I am trying to add a custom JSON DataStore and DataStoreConfiguration for SwiftData. Apple kindly provided some sample code in the WWDC24 session, "Create a custom data store with SwiftData", and (once updated for API changes since WWDC) that works fine.

However, when I try to add a relationship between two classes, it fails. Has anyone successfully made a JSONDataStore with a relationship?

Here's my code; firstly the cleaned up code from the WWDC session:

import SwiftData

final class JSONStoreConfiguration: DataStoreConfiguration {
    typealias Store = JSONStore
    
    var name: String
    var schema: Schema?
    var fileURL: URL
    
    init(name: String, schema: Schema? = nil, fileURL: URL) {
        self.name = name
        self.schema = schema
        self.fileURL = fileURL
    }
    
    static func == (lhs: JSONStoreConfiguration, rhs: JSONStoreConfiguration) -> Bool {
        return lhs.name == rhs.name
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(name)
    }
}

final class JSONStore: DataStore {
    typealias Configuration = JSONStoreConfiguration
    typealias Snapshot = DefaultSnapshot
    
    var configuration: JSONStoreConfiguration
    var name: String
    var schema: Schema
    var identifier: String
    
    init(_ configuration: JSONStoreConfiguration, migrationPlan: (any SchemaMigrationPlan.Type)?) throws {
        self.configuration = configuration
        self.name = configuration.name
        self.schema = configuration.schema!
        self.identifier = configuration.fileURL.lastPathComponent
    }
    
    func save(_ request: DataStoreSaveChangesRequest<DefaultSnapshot>) throws -> DataStoreSaveChangesResult<DefaultSnapshot> {
        var remappedIdentifiers = [PersistentIdentifier: PersistentIdentifier]()
        var serializedData = try read()
        
        for snapshot in request.inserted {
            let permanentIdentifier = try PersistentIdentifier.identifier(for: identifier,
                                                                          entityName: snapshot.persistentIdentifier.entityName,
                                                                          primaryKey: UUID())
            let permanentSnapshot = snapshot.copy(persistentIdentifier: permanentIdentifier)
            
            serializedData[permanentIdentifier] = permanentSnapshot
            remappedIdentifiers[snapshot.persistentIdentifier] = permanentIdentifier
        }
        
        for snapshot in request.updated {
            serializedData[snapshot.persistentIdentifier] = snapshot
        }
        
        for snapshot in request.deleted {
            serializedData[snapshot.persistentIdentifier] = nil
        }
        
        try write(serializedData)
        
        return DataStoreSaveChangesResult<DefaultSnapshot>(for: self.identifier, remappedIdentifiers: remappedIdentifiers)
    }
    
    func fetch<T>(_ request: DataStoreFetchRequest<T>) throws -> DataStoreFetchResult<T, DefaultSnapshot> where T : PersistentModel {
        if request.descriptor.predicate != nil {
            throw DataStoreError.preferInMemoryFilter
        } else if request.descriptor.sortBy.count > 0 {
            throw DataStoreError.preferInMemorySort
        }
        
        let objs = try read()
        let snapshots = objs.values.map({ $0 })
        
        return DataStoreFetchResult(descriptor: request.descriptor, fetchedSnapshots: snapshots, relatedSnapshots: objs)
    }
    
    func read() throws -> [PersistentIdentifier : DefaultSnapshot] {
        if FileManager.default.fileExists(atPath: configuration.fileURL.path(percentEncoded: false)) {
            let decoder = JSONDecoder()
            
            decoder.dateDecodingStrategy = .iso8601
            
            let data = try decoder.decode([DefaultSnapshot].self, from: try Data(contentsOf: configuration.fileURL))
            var result = [PersistentIdentifier: DefaultSnapshot]()
            
            data.forEach { s in
                result[s.persistentIdentifier] = s
            }
            
            return result
        } else {
            return [:]
        }
    }
    
    func write(_ data: [PersistentIdentifier : DefaultSnapshot]) throws {
        let encoder = JSONEncoder()
        
        encoder.dateEncodingStrategy = .iso8601
        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
        
        let jsonData = try encoder.encode(data.values.map({ $0 }))
        
        try jsonData.write(to: configuration.fileURL)
    }
}

The data model classes:

import SwiftData

@Model
class Settings {
    private(set) var version = 1
    
    @Relationship(deleteRule: .cascade) var hack: Hack? = Hack()
    
    init() {
    }
}

@Model
class Hack {
    var foo = "Foo"
    
    var bar = 42
    
    init() {
    }
}

Container:

lazy var mainContainer: ModelContainer = {
        do {
            let url = // URL to file
            let configuration = JSONStoreConfiguration(name: "Settings", schema: Schema([Settings.self, Hack.self]), fileURL: url)
            
            return try ModelContainer(for: Settings.self, Hack.self, configurations: configuration)
        }
        catch {
            fatalError("Container error: \(error.localizedDescription)")
        }
    }()

Load function, that saves a new Settings JSON file if there isn't an existing one:

    @MainActor func loadSettings() {
        let mainContext = mainContainer.mainContext
        let descriptor = FetchDescriptor<Settings>()

        let settingsArray = try? mainContext.fetch(descriptor)
        
        print("\(settingsArray?.count ?? 0) settings found")
        
        if let settingsArray, let settings = settingsArray.last {
            print("Loaded")
        } else {
            let settings = Settings()
            
            mainContext.insert(settings)
            
            do {
                try mainContext.save()
            } catch {
                print("Error saving settings: \(error)")
            }
        }
    }

The save operation creates a JSON file, which while it isn't a format I would choose, is acceptable, though I notice that the "hack" property (the relationship) doesn't have the correct identifier.

When I run the app again to load the data, I get an error (that there wasn't room to include in this post).

Even if I change Apple's code to not assign a new identifier, so the relationship property and its pointee have the same identifier, it still doesn't load.

Am I doing something obviously wrong, or are relationships not supported in custom data stores?

Since there wasn't room in the post for the created JSON, here it is:

[
  {
    "hack" : {
      "implementation" : {
        "entityName" : "Hack",
        "isTemporary" : true,
        "primaryKey" : "52747F74-ED6C-40FF-B008-ED3AA1AEF8EB",
        "uriRepresentation" : "x-swiftdata:\/\/Hack\/52747F74-ED6C-40FF-B008-ED3AA1AEF8EB"
      }
    },
    "persistentIdentifier" : {
      "implementation" : {
        "entityName" : "Settings",
        "isTemporary" : false,
        "primaryKey" : "D767DB6F-8D55-4261-85DC-536F6E77D81D",
        "storeIdentifier" : "Settings.timeoutsettings",
        "typedPrimaryKey" : "D767DB6F-8D55-4261-85DC-536F6E77D81D",
        "uriRepresentation" : "x-developer-provided:\/\/Settings.timeoutsettings\/Settings\/D767DB6F-8D55-4261-85DC-536F6E77D81D"
      }
    },
    "version" : 1
  },
  {
    "bar" : 42,
    "foo" : "Foo",
    "persistentIdentifier" : {
      "implementation" : {
        "entityName" : "Hack",
        "isTemporary" : false,
        "primaryKey" : "769933C4-3ADE-4808-914C-C21EA6833F56",
        "storeIdentifier" : "Settings.timeoutsettings",
        "typedPrimaryKey" : "769933C4-3ADE-4808-914C-C21EA6833F56",
        "uriRepresentation" : "x-developer-provided:\/\/Settings.timeoutsettings\/Hack\/769933C4-3ADE-4808-914C-C21EA6833F56"
      }
    }
  }
]

And the error:

SwiftData/ModelContext.swift:2595: Fatal error: Failed to materialize a model for Settings from snapshot:DefaultSnapshot(_values: ["bar": 42, "foo": "Foo"], persistentIdentifier: SwiftData.PersistentIdentifier(id: SwiftData.PersistentIdentifier.ID(url: x-developer-provided://Settings.timeoutsettings/Hack/769933C4-3ADE-4808-914C-C21EA6833F56), implementation: SwiftData.PersistentIdentifierImplementation)) For fetch descriptor: FetchDescriptor<Settings>(predicate: nil, sortBy: [], fetchLimit: nil, fetchOffset: Optional(0), includePendingChanges: true, propertiesToFetch: [], relationshipKeyPathsForPrefetching: [], returnModelsAsFutures: false)

SwiftData JSONDataStore with relationships
 
 
Q