ios+mac: evict messages when a stored delete tombstone arrives (#582) (#791)
ServerMessage gains tombstone_of; every stored-row ingest path folds it as the live delete frame does: - ChatFeedModel.foldPageDetached (history open, scroll-back, socket re-pull) evicts before the fold — list, group-app events, MessageStore + FTS — silently (a reconcile earns no implosion animation) - BackgroundSync drops targets from the store before filing a page, so a message unsent while the app was suspended leaves the store (and search) without the app ever opening the thread - tombstoneEvictions is a pure nonisolated helper shared by both, pinned by TombstoneVectorTests against the tombstone_delete vector (fixture mirrors test/event-vectors/chat.json, per the existing *-vector.json pattern); legacy rows without the field decode unchanged Known edge (accepted, matches web): the thread list's last_at excludes tombstones server-side, so a delete-only change doesn't wake BackgroundSync for that thread — eviction happens at the next feed open instead. swift test green (full LChatCore suite incl. 3 new vector tests); both app targets build. No changelog — native entries wait for the release mint.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+114
−1
ios/LChatCore/Sources/LChatCore/BackgroundSync.swift+7 −0ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift+34 −0ios/LChatCore/Sources/LChatCore/Models.swift+6 −1ios/LChatCore/Tests/LChatCoreTests/Fixtures/tombstone-vector.json+15 −0ios/LChatCore/Tests/LChatCoreTests/TombstoneVectorTests.swift+52 −0
diff --git a/ios/LChatCore/Sources/LChatCore/BackgroundSync.swift b/ios/LChatCore/Sources/LChatCore/BackgroundSync.swift| index 072e6ce..fb58a26 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/BackgroundSync.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/BackgroundSync.swift |
| @@ -44,7 +44,14 @@ public enum BackgroundSync { |
| if local.at > 0, (t.last_at ?? 0) <= local.at { continue } |
| let rows = await api.messages(thread: t.id, after: local.id) |
| guard !rows.isEmpty else { continue } |
| + // Deletes that happened while the app was suspended arrive as |
| + // tombstone rows (#582) — drop the targets from the store (and |
| + // its FTS index) before filing the page's real messages. |
| + for id in ChatFeedModel.tombstoneEvictions(rows) { |
| + await MessageStore.shared.delete(instance: inst.id, thread: t.id, id: id) |
| + } |
| let batch: [MessageStore.StoredMessage] = rows.compactMap { row in |
| + guard (row.tombstone_of ?? 0) <= 0 else { return nil } |
| guard let p = ChatFeedModel.renderable(row.blob, key: key, |
| group: t.kind == "group") else { return nil } |
| // Reactions are encrypted events now — rows carry no tally; |
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift| index 6517af3..cd8c972 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift |
| @@ -888,8 +888,41 @@ public final class ChatFeedModel: ObservableObject { |
| messages = list |
| } |
| + /// The message ids a fetched page's delete tombstones evict (#582): each |
| + /// tombstone row is the stored form of the live "delete" frame, for |
| + /// clients that were offline when it fired. Pure (hence nonisolated) — |
| + /// shared with BackgroundSync and pinned by the tombstone_delete vector. |
| + nonisolated public static func tombstoneEvictions(_ rows: [ServerMessage]) -> [Int64] { |
| + rows.compactMap { row in |
| + guard let t = row.tombstone_of, t > 0 else { return nil } |
| + return t |
| + } |
| + } |
| + |
| private func foldPageDetached(_ rows: [ServerMessage]) async { |
| guard let key else { return } |
| + // Deletes that happened while this device was away: evict before the |
| + // fold, mirroring the live "delete" frame (list, group-app events, |
| + // store) — minus its implosion animation; a reconcile is silent. |
| + let evict = Self.tombstoneEvictions(rows) |
| + if !evict.isEmpty { |
| + var pruned = messages |
| + pruned.removeAll { evict.contains($0.id) } |
| + if pruned.count != messages.count { |
| + Self.refreshReplyCounts(&pruned) |
| + messages = pruned |
| + } |
| + var unfolded = false |
| + for id in evict where groupAppsEvents.removeValue(forKey: id) != nil { unfolded = true } |
| + if unfolded { recomputeGroupApps() } |
| + let instanceID = instance.id |
| + let tid = threadID |
| + Task.detached(priority: .utility) { |
| + for id in evict { |
| + await MessageStore.shared.delete(instance: instanceID, thread: tid, id: id) |
| + } |
| + } |
| + } |
| let myUserId = self.myUserId |
| let screenName = self.instance.screenName |
| let wantGroupApps = threadKind == "group" |
| @@ -901,6 +934,7 @@ public final class ChatFeedModel: ObservableObject { |
| var events: [(Int64, GroupApps.Event)] = [] |
| var reacts: [MessagePayload] = [] |
| for row in rows { |
| + if (row.tombstone_of ?? 0) > 0 { continue } // evicted above; empty blob, nothing to fold |
| if wantGroupApps, let e = Self.groupAppsEvent(row.blob, key: key) { |
| events.append((row.id, e)) |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift| index fd32d66..2b6db46 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Models.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Models.swift |
| @@ -110,12 +110,17 @@ public struct RosterEntry: Codable, Equatable { |
| // GET /api/messages row. The blob is opaque to the server; it may contain an |
| // encrypted reaction event (type="reaction"), which the client decrypts and folds |
| -// into message reactions. edited_at marks server-side edits. |
| +// into message reactions. edited_at marks server-side edits. A non-nil |
| +// tombstone_of marks a delete tombstone (#581): the server hard-deleted that |
| +// message id and left this empty row so forward catch-up conveys the delete — |
| +// fold it exactly like the live "delete" frame (the tombstone_delete vector in |
| +// chat.json pins this). |
| public struct ServerMessage: Codable, Identifiable { |
| public let id: Int64 |
| public let created_at: Int64 |
| public let blob: String |
| public var edited_at: Int64? |
| + public var tombstone_of: Int64? |
| } |
| // One emoji's tally on a message. Decodes both server shapes: history rows |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/Fixtures/tombstone-vector.json b/ios/LChatCore/Tests/LChatCoreTests/Fixtures/tombstone-vector.json| new file mode 100644 |
| index 0000000..7bbd6eb |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/Fixtures/tombstone-vector.json |
| @@ -0,0 +1,15 @@ |
| +{ |
| + "name": "tombstone_delete", |
| + "description": "A stored delete tombstone (#581/#583): the server hard-deleted message 12345 and left a fresh empty row naming it in tombstone_of. History pages and reconnect catch-up must fold it to the SAME event the live delete frame produces \u2014 one delete path, no gap-inference.", |
| + "server_message": { |
| + "id": 12360, |
| + "created_at": 1751900000, |
| + "blob": "", |
| + "tombstone_of": 12345 |
| + }, |
| + "expected_fold_event": { |
| + "type": "chat.delete", |
| + "msgId": 12345 |
| + }, |
| + "note": "The tombstone's own id (12360) is deliberately fresh \u2014 above every client watermark \u2014 so forward-only after: sync always delivers it. Clients with no local copy of 12345 fold it as a no-op. Old clients that predate tombstone_of skip the row entirely (an empty blob fails decrypt)." |
| +} |
| \ No newline at end of file |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/TombstoneVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/TombstoneVectorTests.swift| new file mode 100644 |
| index 0000000..24c029a |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/TombstoneVectorTests.swift |
| @@ -0,0 +1,52 @@ |
| +import XCTest |
| +@testable import LChatCore |
| + |
| +// 🤖 The stored-tombstone contract (#581/#582), pinned by the tombstone_delete |
| +// vector (canonical copy: test/event-vectors/chat.json — this fixture mirrors |
| +// it, like the other *-vector.json files). A history/catch-up row carrying |
| +// tombstone_of must evict that message exactly as the live "delete" frame |
| +// does; old rows without the field decode unchanged. |
| +final class TombstoneVectorTests: XCTestCase { |
| + private struct Vector: Codable { |
| + let name: String |
| + let server_message: ServerMessage |
| + let expected_fold_event: Fold |
| + struct Fold: Codable { |
| + let type: String |
| + let msgId: Int64 |
| + } |
| + } |
| + |
| + private func loadVector() throws -> Vector { |
| + let url = try XCTUnwrap(Bundle.module.url(forResource: "Fixtures/tombstone-vector", withExtension: "json"), |
| + "tombstone-vector.json missing from test fixtures") |
| + return try JSONDecoder().decode(Vector.self, from: Data(contentsOf: url)) |
| + } |
| + |
| + func testTombstoneRowDecodes() throws { |
| + let v = try loadVector() |
| + XCTAssertEqual(v.expected_fold_event.type, "chat.delete") |
| + let row = v.server_message |
| + XCTAssertEqual(row.tombstone_of, v.expected_fold_event.msgId, "the tombstone names the id to drop") |
| + XCTAssertEqual(row.blob, "", "tombstones carry no ciphertext") |
| + XCTAssertGreaterThan(row.id, row.tombstone_of ?? 0, |
| + "tombstone ids are fresh — above every client's forward watermark") |
| + } |
| + |
| + func testEvictionsMatchTheVector() throws { |
| + let v = try loadVector() |
| + // A page mixing an ordinary row with the tombstone: only the |
| + // tombstone's target is evicted; plain rows are untouched. |
| + let plain = ServerMessage(id: 12344, created_at: 1751899999, blob: "ciphertext", edited_at: nil, tombstone_of: nil) |
| + let evict = ChatFeedModel.tombstoneEvictions([plain, v.server_message]) |
| + XCTAssertEqual(evict, [v.expected_fold_event.msgId]) |
| + } |
| + |
| + func testLegacyRowsWithoutTheFieldDecode() throws { |
| + // Pre-#581 servers (and non-tombstone rows) omit the field entirely. |
| + let json = #"{"id": 7, "created_at": 1700000000, "blob": "abc"}"# |
| + let row = try JSONDecoder().decode(ServerMessage.self, from: Data(json.utf8)) |
| + XCTAssertNil(row.tombstone_of) |
| + XCTAssertTrue(ChatFeedModel.tombstoneEvictions([row]).isEmpty) |
| + } |
| +} |