finish SwiftData migration with @Query views
- Drive chat list, timeline and forward picker from SwiftData @Query instead of AppModel's in-memory arrays; inject the per-account ModelContext from RootView with an in-memory fallback. - Physically delete locally deleted messages (context.delete) and purge rows marked deleted by older builds on store open, so @Query views never resurrect them. - Delete the legacy JSON snapshot only after a successful import save and restore the completeUntilFirstUserAuthentication data protection attribute on the store file. - Mirror the old ChatArchive tolerant decoding in LegacyArchiveImporter so snapshots from older schema versions (missing reactions, isGroupMessage, roster fields) still import. - Cover the new behaviour in ArchiveStoreTests and guard it with new verify.sh invariants; update AGENTS.md, ARCHITECTURE.md and SECURITY.md.
This commit is contained in:
1 parent
277d8654c3
commit
2884810ec4
16 files changed
+653
-65
No files matched your search
+109
-26
@@ -45,7 +45,6 @@ final class ArchiveStoreTests: XCTestCase {
|
||||
func testLegacyImportMapsConversationAndMessage() throws {
|
||||
let jid = "test-\(UUID().uuidString)@example.org"
|
||||
let legacy = LegacyArchiveImporter.Snapshot(
|
||||
schemaVersion: 1,
|
||||
conversations: [
|
||||
LegacyArchiveImporter.LegacyConversation(
|
||||
id: "bob@example.org",
|
||||
@@ -75,32 +74,9 @@ final class ArchiveStoreTests: XCTestCase {
|
||||
direction: .outgoing,
|
||||
delivery: .sent,
|
||||
security: .omemo,
|
||||
kind: .text,
|
||||
remoteAttachmentURL: nil,
|
||||
localFilename: nil,
|
||||
mimeType: nil,
|
||||
duration: nil,
|
||||
byteCount: nil,
|
||||
encryptionFingerprint: nil,
|
||||
editedAt: nil,
|
||||
replyToID: nil,
|
||||
replyToJID: nil,
|
||||
replyPreview: nil,
|
||||
forwardedFrom: nil,
|
||||
retractedAt: nil,
|
||||
originID: nil,
|
||||
stanzaID: nil,
|
||||
senderDisplayName: nil,
|
||||
isGroupMessage: false,
|
||||
callHistory: nil,
|
||||
reactions: []
|
||||
kind: .text
|
||||
)
|
||||
],
|
||||
locallyDeletedMessageIDs: [],
|
||||
rosterContactJIDs: [],
|
||||
lastSuccessfulMAMSync: nil,
|
||||
lastSuccessfulMAMCursor: nil,
|
||||
mamCheckpoints: [:]
|
||||
]
|
||||
)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
@@ -117,4 +93,111 @@ final class ArchiveStoreTests: XCTestCase {
|
||||
XCTAssertEqual(imported?.messages.map(\.clientID), ["msg-1"])
|
||||
XCTAssertEqual(imported?.messages.first?.conversation?.jid, "bob@example.org")
|
||||
}
|
||||
|
||||
func testLegacyImportToleratesMissingOptionalFields() throws {
|
||||
// Snapshots written by older schema versions lack fields that were
|
||||
// added later (reactions, isGroupMessage, rosterContactJIDs, ...).
|
||||
// The importer must decode them with defaults, exactly like the
|
||||
// removed ChatArchive did.
|
||||
let raw: [String: Any] = [
|
||||
"schemaVersion": 2,
|
||||
"conversations": [
|
||||
[
|
||||
"id": "bob@example.org",
|
||||
"jid": "bob@example.org",
|
||||
"displayName": "Bob",
|
||||
"lastMessage": "",
|
||||
"lastActivity": 1_700_000_000_000.0,
|
||||
"unreadCount": 0,
|
||||
"isOnline": false,
|
||||
"isPinned": false,
|
||||
]
|
||||
],
|
||||
"messages": [
|
||||
[
|
||||
"id": "msg-1",
|
||||
"conversationID": "bob@example.org",
|
||||
"senderJID": "alice@example.org",
|
||||
"body": "Hello",
|
||||
"timestamp": 1_700_000_000_000.0,
|
||||
"direction": "outgoing",
|
||||
"delivery": "sent",
|
||||
"security": "omemo",
|
||||
"kind": "text",
|
||||
]
|
||||
],
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: raw)
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("legacy-old-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
try data.write(to: url)
|
||||
|
||||
let imported = try LegacyArchiveImporter.importIfNeeded(from: url)
|
||||
XCTAssertNotNil(imported)
|
||||
XCTAssertEqual(imported?.conversations.map(\.jid), ["bob@example.org"])
|
||||
XCTAssertEqual(imported?.conversations.first?.kind, .direct)
|
||||
XCTAssertEqual(imported?.conversations.first?.isGroupJoined, false)
|
||||
XCTAssertEqual(imported?.messages.map(\.clientID), ["msg-1"])
|
||||
XCTAssertEqual(imported?.messages.first?.isGroupMessage, false)
|
||||
XCTAssertEqual(imported?.messages.first?.reactions ?? [], [])
|
||||
XCTAssertEqual(imported?.rosterContactJIDs ?? [], [])
|
||||
XCTAssertEqual(imported?.mamCheckpoints ?? [], [])
|
||||
XCTAssertNil(imported?.lastSuccessfulMAMSync)
|
||||
}
|
||||
|
||||
func testLocallyDeletedRowsArePurgedFromTheStore() throws {
|
||||
// @Query views read the store directly, so messages that were only
|
||||
// marked as locally deleted must be physically removed on open.
|
||||
let jid = "test-\(UUID().uuidString)@example.org"
|
||||
let store = try ArchiveStore(accountJID: jid)
|
||||
defer { try? store.erase() }
|
||||
|
||||
let conversation = Conversation(jid: "bob@example.org", displayName: "Bob")
|
||||
let message = ChatMessage(
|
||||
conversationID: conversation.jid,
|
||||
senderJID: jid,
|
||||
body: "Will be deleted",
|
||||
direction: .outgoing,
|
||||
delivery: .sent,
|
||||
security: .omemo
|
||||
)
|
||||
store.context.insert(conversation)
|
||||
store.context.insert(message)
|
||||
let deletionKey = ArchiveStore.deletionKey(
|
||||
messageID: message.clientID,
|
||||
conversationID: conversation.jid
|
||||
)
|
||||
try store.save(
|
||||
locallyDeletedMessageIDs: [deletionKey],
|
||||
rosterContactJIDs: [],
|
||||
lastSuccessfulMAMSync: nil,
|
||||
lastSuccessfulMAMCursor: nil,
|
||||
mamCheckpoints: [:]
|
||||
)
|
||||
|
||||
store.purgeLocallyDeletedMessages()
|
||||
let loaded = store.load()
|
||||
XCTAssertTrue(loaded.messages.isEmpty)
|
||||
XCTAssertEqual(loaded.locallyDeletedMessageIDs, Set([deletionKey]))
|
||||
}
|
||||
|
||||
func testCorruptLegacyJSONIsKeptForRetry() throws {
|
||||
let jid = "test-\(UUID().uuidString)@example.org"
|
||||
let directory = FileManager.default.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first!.appendingPathComponent("Luma/Accounts", isDirectory: true)
|
||||
let legacyURL = directory.appendingPathComponent("\(ArchiveStore.stableHash(jid)).json")
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try Data("not-json".utf8).write(to: legacyURL)
|
||||
defer { try? FileManager.default.removeItem(at: legacyURL) }
|
||||
|
||||
let store = try ArchiveStore(accountJID: jid)
|
||||
defer { try? store.erase() }
|
||||
XCTAssertTrue(store.load().conversations.isEmpty)
|
||||
// The unreadable snapshot must stay on disk instead of being deleted
|
||||
// together with the user's history.
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: legacyURL.path))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user