eleven / messenger Public

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>
Paul Campbell 96ee9dd79c8afda880dd5022bde6cc109e7be070 parent 94e2c7a
5 files changed, +114 −1
  • ios/LChatCore/Sources/LChatCore/BackgroundSync.swift +7 −0
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +34 −0
  • ios/LChatCore/Sources/LChatCore/Models.swift +6 −1
  • ios/LChatCore/Tests/LChatCoreTests/Fixtures/tombstone-vector.json +15 −0
  • ios/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)
+ }
+}