migrate persistence from JSON to SwiftData
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

Models:
- Convert Conversation and ChatMessage to SwiftData @Model classes with
  a
  one-to-many relationship and cascade delete.
- Rename ChatMessage.id to clientID and drop Conversation.id in favor of
  jid
  (the string id would clash with PersistentIdentifier).
- Keep MessageReaction as a Codable value stored in an array attribute.

Persistence:
- Add ArchiveStore, a per-account ModelContainer/ModelContext that
  replaces
  the JSON ChatArchive with load/save/erase.
- Add ArchiveMetadataRecord for the durable MAM checkpoints, cursor,
  locally
  deleted message IDs and roster contact JIDs.
- Add LegacyArchiveImporter to import the old JSON snapshot once and
  delete
  the file afterwards, preserving existing history.

AppModel:
- Replace ChatArchive with ArchiveStore and persist via context.save();
  insert new models and delete the replaced object on upsert merges so
  SwiftData never keeps an orphaned duplicate.

Tests:
- Replace ChatArchiveTests with ArchiveStoreTests (store round-trip and
  legacy import) and drop the now-obsolete Codable round-trip
  assertions.
This commit is contained in:
wt committed 2026-08-18 22:58:18 +07:00
1 parent 0317277d46
commit cbb820a86e
30 files changed
+726 -665

No files matched your search

@@ -0,0 +1,37 @@
import Foundation
import SwiftData
/// Codable representation of a single durable MAM checkpoint, keyed by the
/// archive it belongs to (account or a specific MUC).
struct MAMCheckpointEntry: Codable, Equatable, Sendable {
let key: MAMArchiveKey
let checkpoint: MAMArchiveCheckpoint
}
/// Singleton per-account metadata that used to live in the JSON snapshot:
/// locally deleted message IDs, roster contact JIDs and MAM checkpoints.
@Model
final class ArchiveMetadata {
@Attribute(.unique) var accountJID: String
var locallyDeletedMessageIDs: [String]
var rosterContactJIDs: [String]
var lastSuccessfulMAMSync: Date?
var lastSuccessfulMAMCursor: String?
var mamCheckpoints: [MAMCheckpointEntry]
init(
accountJID: String,
locallyDeletedMessageIDs: [String] = [],
rosterContactJIDs: [String] = [],
lastSuccessfulMAMSync: Date? = nil,
lastSuccessfulMAMCursor: String? = nil,
mamCheckpoints: [MAMCheckpointEntry] = []
) {
self.accountJID = accountJID
self.locallyDeletedMessageIDs = locallyDeletedMessageIDs
self.rosterContactJIDs = rosterContactJIDs
self.lastSuccessfulMAMSync = lastSuccessfulMAMSync
self.lastSuccessfulMAMCursor = lastSuccessfulMAMCursor
self.mamCheckpoints = mamCheckpoints
}
}
@@ -0,0 +1,134 @@
import Foundation
import SwiftData
/// SwiftData-backed replacement for the legacy JSON `ChatArchive`. Owns the
/// per-account `ModelContainer`/`ModelContext`, imports the old JSON snapshot
/// once, and exposes load/save/erase.
@MainActor
final class ArchiveStore {
struct Loaded {
var conversations: [Conversation]
var messages: [ChatMessage]
var locallyDeletedMessageIDs: Set<String>
var rosterContactJIDs: Set<String>
var lastSuccessfulMAMSync: Date?
var lastSuccessfulMAMCursor: String?
var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
}
let container: ModelContainer
let context: ModelContext
private let accountJID: String
private let storeURL: URL
private let legacyJSONURL: URL
private(set) var metadata: ArchiveMetadata?
init(accountJID: String) throws {
self.accountJID = accountJID
let fileManager = FileManager.default
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? fileManager.temporaryDirectory
let directory = base.appendingPathComponent("Luma/Accounts", isDirectory: true)
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
let hash = Self.stableHash(accountJID)
storeURL = directory.appendingPathComponent("\(hash).store")
legacyJSONURL = directory.appendingPathComponent("\(hash).json")
let schema = Schema([
Conversation.self,
ChatMessage.self,
ArchiveMetadata.self,
])
let configuration = ModelConfiguration(schema: schema, url: storeURL)
container = try ModelContainer(for: schema, configurations: [configuration])
context = ModelContext(container)
context.autosaveEnabled = false
migrateLegacyJSONIfNeeded()
}
func load() -> Loaded {
let conversationSort = [SortDescriptor(\Conversation.lastActivity, order: .reverse)]
let conversations = (try? context.fetch(FetchDescriptor<Conversation>(sortBy: conversationSort))) ?? []
let messages = (try? context.fetch(FetchDescriptor<ChatMessage>())) ?? []
let metadata = fetchMetadata()
let mamCheckpoints = Dictionary(
metadata.mamCheckpoints.map { ($0.key, $0.checkpoint) },
uniquingKeysWith: { first, _ in first }
)
return Loaded(
conversations: conversations,
messages: messages,
locallyDeletedMessageIDs: Set(metadata.locallyDeletedMessageIDs),
rosterContactJIDs: Set(metadata.rosterContactJIDs),
lastSuccessfulMAMSync: metadata.lastSuccessfulMAMSync,
lastSuccessfulMAMCursor: metadata.lastSuccessfulMAMCursor,
mamCheckpoints: mamCheckpoints
)
}
/// Persists the passed metadata plus all pending model mutations.
func save(
locallyDeletedMessageIDs: Set<String>,
rosterContactJIDs: Set<String>,
lastSuccessfulMAMSync: Date?,
lastSuccessfulMAMCursor: String?,
mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
) throws {
let metadata = fetchMetadata()
metadata.locallyDeletedMessageIDs = Array(locallyDeletedMessageIDs)
metadata.rosterContactJIDs = Array(rosterContactJIDs)
metadata.lastSuccessfulMAMSync = lastSuccessfulMAMSync
metadata.lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(lastSuccessfulMAMCursor)
metadata.mamCheckpoints = mamCheckpoints.map {
MAMCheckpointEntry(key: $0.key, checkpoint: $0.value)
}
try context.save()
}
func erase() throws {
try context.delete(model: Conversation.self)
try context.delete(model: ChatMessage.self)
try context.delete(model: ArchiveMetadata.self)
try context.save()
}
private func fetchMetadata() -> ArchiveMetadata {
if let metadata { return metadata }
let existing = try? context.fetch(FetchDescriptor<ArchiveMetadata>()).first
let metadata = existing ?? ArchiveMetadata(accountJID: accountJID)
context.insert(metadata)
self.metadata = metadata
return metadata
}
private func migrateLegacyJSONIfNeeded() {
guard FileManager.default.fileExists(atPath: legacyJSONURL.path) else { return }
guard let imported = try? LegacyArchiveImporter.importIfNeeded(from: legacyJSONURL) else {
return
}
for conversation in imported.conversations {
context.insert(conversation)
}
for message in imported.messages {
context.insert(message)
}
let metadata = fetchMetadata()
metadata.locallyDeletedMessageIDs = imported.locallyDeletedMessageIDs
metadata.rosterContactJIDs = imported.rosterContactJIDs
metadata.lastSuccessfulMAMSync = imported.lastSuccessfulMAMSync
metadata.lastSuccessfulMAMCursor = imported.lastSuccessfulMAMCursor
metadata.mamCheckpoints = imported.mamCheckpoints
try? context.save()
try? FileManager.default.removeItem(at: legacyJSONURL)
}
private static func stableHash(_ value: String) -> String {
let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in
(partial ^ UInt64(byte)) &* 1_099_511_628_211
}
return String(hash, radix: 16, uppercase: false)
}
}
@@ -1,125 +0,0 @@
import Foundation
actor ChatArchive {
struct Snapshot: Codable, Sendable {
static let currentSchemaVersion = 6
var schemaVersion: Int
var conversations: [Conversation]
var messages: [ChatMessage]
var locallyDeletedMessageIDs: Set<String>
var rosterContactJIDs: Set<String>
var lastSuccessfulMAMSync: Date?
var lastSuccessfulMAMCursor: String?
var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
init(
conversations: [Conversation],
messages: [ChatMessage],
locallyDeletedMessageIDs: Set<String> = [],
rosterContactJIDs: Set<String> = [],
lastSuccessfulMAMSync: Date? = nil,
lastSuccessfulMAMCursor: String? = nil,
mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint] = [:]
) {
self.schemaVersion = Self.currentSchemaVersion
self.conversations = conversations
self.messages = messages
self.locallyDeletedMessageIDs = locallyDeletedMessageIDs
self.rosterContactJIDs = rosterContactJIDs
self.lastSuccessfulMAMSync = lastSuccessfulMAMSync
self.lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(
lastSuccessfulMAMCursor
)
self.mamCheckpoints = mamCheckpoints
}
private enum CodingKeys: String, CodingKey {
case schemaVersion
case conversations
case messages
case locallyDeletedMessageIDs
case rosterContactJIDs
case lastSuccessfulMAMSync
case lastSuccessfulMAMCursor
case mamCheckpoints
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try values.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
conversations = try values.decode([Conversation].self, forKey: .conversations)
messages = try values.decode([ChatMessage].self, forKey: .messages)
locallyDeletedMessageIDs = try values.decodeIfPresent(
Set<String>.self,
forKey: .locallyDeletedMessageIDs
) ?? []
rosterContactJIDs = try values.decodeIfPresent(
Set<String>.self,
forKey: .rosterContactJIDs
) ?? []
lastSuccessfulMAMSync = try values.decodeIfPresent(
Date.self,
forKey: .lastSuccessfulMAMSync
)
lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(
try values.decodeIfPresent(String.self, forKey: .lastSuccessfulMAMCursor)
)
mamCheckpoints = try values.decodeIfPresent(
[MAMArchiveKey: MAMArchiveCheckpoint].self, forKey: .mamCheckpoints
) ?? [:]
}
static let empty = Snapshot(conversations: [], messages: [])
}
private let fileURL: URL
private let encoder: JSONEncoder
private let decoder: JSONDecoder
init(accountJID: String) {
let fileManager = FileManager.default
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? fileManager.temporaryDirectory
let directory = base.appendingPathComponent("Luma/Accounts", isDirectory: true)
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
fileURL = directory.appendingPathComponent("\(Self.stableHash(accountJID)).json")
encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
encoder.dateEncodingStrategy = .millisecondsSince1970
decoder = JSONDecoder()
decoder.dateDecodingStrategy = .millisecondsSince1970
}
func load() -> Snapshot {
guard let data = try? Data(contentsOf: fileURL),
let snapshot = try? decoder.decode(Snapshot.self, from: data) else {
return .empty
}
return snapshot
}
func save(_ snapshot: Snapshot) throws {
let data = try encoder.encode(snapshot)
try data.write(to: fileURL, options: [.atomic])
#if os(iOS)
try? FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: fileURL.path
)
#endif
}
func erase() throws {
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
try FileManager.default.removeItem(at: fileURL)
}
private static func stableHash(_ value: String) -> String {
let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in
(partial ^ UInt64(byte)) &* 1_099_511_628_211
}
return String(hash, radix: 16, uppercase: false)
}
}
@@ -0,0 +1,160 @@
import Foundation
/// Reads the legacy JSON snapshot and converts it into SwiftData `@Model`
/// objects once, so existing history survives the migration from the JSON file.
enum LegacyArchiveImporter {
struct LegacyConversation: Codable {
enum Kind: String, Codable { case direct, group }
var id: String
var jid: String
var displayName: String
var lastMessage: String
var lastActivity: Date
var unreadCount: Int
var isOnline: Bool
var isPinned: Bool
var encryptionPreference: EncryptionPreference
var kind: Kind
var groupNickname: String?
var isGroupJoined: Bool
var shouldAutojoin: Bool
var occupantCount: Int
var invitedBy: String?
}
struct LegacyMessage: Codable {
enum Direction: String, Codable { case incoming, outgoing }
enum Delivery: String, Codable { case sending, sent, delivered, failed }
enum Security: String, Codable { case omemo, plaintext, decryptionFailed }
enum Kind: String, Codable {
case text, attachment, photo, video, audio, voice, videoNote, location, system
}
var id: String
var conversationID: String
var senderJID: String
var body: String
var timestamp: Date
var direction: Direction
var delivery: Delivery
var security: Security
var kind: Kind
var remoteAttachmentURL: String?
var localFilename: String?
var mimeType: String?
var duration: TimeInterval?
var byteCount: Int?
var encryptionFingerprint: String?
var editedAt: Date?
var replyToID: String?
var replyToJID: String?
var replyPreview: String?
var forwardedFrom: String?
var retractedAt: Date?
var originID: String?
var stanzaID: String?
var senderDisplayName: String?
var isGroupMessage: Bool
var callHistory: CallHistoryMetadata?
var reactions: [MessageReaction]
}
struct Snapshot: Codable {
var schemaVersion: Int
var conversations: [LegacyConversation]
var messages: [LegacyMessage]
var locallyDeletedMessageIDs: Set<String>
var rosterContactJIDs: Set<String>
var lastSuccessfulMAMSync: Date?
var lastSuccessfulMAMCursor: String?
var mamCheckpoints: [MAMArchiveKey: MAMArchiveCheckpoint]
}
struct Imported {
let conversations: [Conversation]
let messages: [ChatMessage]
let locallyDeletedMessageIDs: [String]
let rosterContactJIDs: [String]
let lastSuccessfulMAMSync: Date?
let lastSuccessfulMAMCursor: String?
let mamCheckpoints: [MAMCheckpointEntry]
}
static func importIfNeeded(from url: URL) throws -> Imported? {
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
let data = try Data(contentsOf: url)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .millisecondsSince1970
let snapshot = try decoder.decode(Snapshot.self, from: data)
let conversations = snapshot.conversations.map { legacy -> Conversation in
let conversation = Conversation(
jid: legacy.jid,
displayName: legacy.displayName,
lastMessage: legacy.lastMessage,
lastActivity: legacy.lastActivity,
unreadCount: legacy.unreadCount,
isOnline: legacy.isOnline,
isPinned: legacy.isPinned,
encryptionPreference: legacy.encryptionPreference,
kind: legacy.kind == .group ? .group : .direct,
groupNickname: legacy.groupNickname,
isGroupJoined: legacy.isGroupJoined,
shouldAutojoin: legacy.shouldAutojoin,
occupantCount: legacy.occupantCount,
invitedBy: legacy.invitedBy
)
return conversation
}
let conversationByJID = Dictionary(
conversations.map { ($0.jid, $0) },
uniquingKeysWith: { first, _ in first }
)
let messages = snapshot.messages.map { legacy -> ChatMessage in
let message = ChatMessage(
id: legacy.id,
conversationID: legacy.conversationID,
senderJID: legacy.senderJID,
body: legacy.body,
timestamp: legacy.timestamp,
direction: legacy.direction == .outgoing ? .outgoing : .incoming,
delivery: ChatMessage.Delivery(rawValue: legacy.delivery.rawValue) ?? .sent,
security: ChatMessage.Security(rawValue: legacy.security.rawValue) ?? .plaintext,
kind: ChatMessage.Kind(rawValue: legacy.kind.rawValue) ?? .text,
remoteAttachmentURL: legacy.remoteAttachmentURL,
localFilename: legacy.localFilename,
mimeType: legacy.mimeType,
duration: legacy.duration,
byteCount: legacy.byteCount,
encryptionFingerprint: legacy.encryptionFingerprint,
editedAt: legacy.editedAt,
replyToID: legacy.replyToID,
replyToJID: legacy.replyToJID,
replyPreview: legacy.replyPreview,
forwardedFrom: legacy.forwardedFrom,
retractedAt: legacy.retractedAt,
originID: legacy.originID,
stanzaID: legacy.stanzaID,
senderDisplayName: legacy.senderDisplayName,
isGroupMessage: legacy.isGroupMessage,
callHistory: legacy.callHistory,
reactions: legacy.reactions
)
message.conversation = conversationByJID[legacy.conversationID.lowercased()]
return message
}
return Imported(
conversations: conversations,
messages: messages,
locallyDeletedMessageIDs: Array(snapshot.locallyDeletedMessageIDs),
rosterContactJIDs: Array(snapshot.rosterContactJIDs),
lastSuccessfulMAMSync: snapshot.lastSuccessfulMAMSync,
lastSuccessfulMAMCursor: snapshot.lastSuccessfulMAMCursor,
mamCheckpoints: snapshot.mamCheckpoints.map {
MAMCheckpointEntry(key: $0.key, checkpoint: $0.value)
}
)
}
}