Skip to content

Commit a07cc33

Browse files
fire message update state sync events for attachments that are loaded later (#96)
* - * Update DatabaseTickWaits.swift * - * - * Rename attachmentRowIDs → attachmentLoadMessageRowIDs The local in resolvePendingMessageHydrationChanges held message ROWIDs (passed in as forMessageRowIDs), not attachment ROWIDs; rename so the name matches what's actually inside. Generated with [Indent](https://indent.com) Co-Authored-By: KishanBagaria <KishanBagaria@users.noreply.github.com> * Update MappedDatabaseRows.swift --------- Co-authored-by: indent[bot] <216979840+indent[bot]@users.noreply.github.com> Co-authored-by: KishanBagaria <KishanBagaria@users.noreply.github.com>
1 parent 7bf2e06 commit a07cc33

10 files changed

Lines changed: 393 additions & 115 deletions

File tree

src/IMessage/Sources/IMDatabase/Database/IMDatabase+Updates.swift

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,25 @@ package struct UpdatedMessageChange {
2525
package let isNew: Bool
2626
package let wasRead: Bool
2727
package let wasEdited: Bool
28-
package let isPreviewUpdate: Bool
28+
/// A late-hydration update (link preview or attachment load surfacing after
29+
/// the row was first emitted). Consumers treat it identically to an edit:
30+
/// it forces a full repatch rather than a read-receipt patch.
31+
package let isHydrationUpdate: Bool
2932

3033
package init(
3134
rowID: Int,
3235
chatGUID: String,
3336
isNew: Bool,
3437
wasRead: Bool,
3538
wasEdited: Bool,
36-
isPreviewUpdate: Bool = false
39+
isHydrationUpdate: Bool = false
3740
) {
3841
self.rowID = rowID
3942
self.chatGUID = chatGUID
4043
self.isNew = isNew
4144
self.wasRead = wasRead
4245
self.wasEdited = wasEdited
43-
self.isPreviewUpdate = isPreviewUpdate
46+
self.isHydrationUpdate = isHydrationUpdate
4447
}
4548

4649
package func merging(_ other: UpdatedMessageChange) -> UpdatedMessageChange {
@@ -50,7 +53,7 @@ package struct UpdatedMessageChange {
5053
isNew: isNew || other.isNew,
5154
wasRead: wasRead || other.wasRead,
5255
wasEdited: wasEdited || other.wasEdited,
53-
isPreviewUpdate: isPreviewUpdate || other.isPreviewUpdate
56+
isHydrationUpdate: isHydrationUpdate || other.isHydrationUpdate
5457
)
5558
}
5659
}

src/IMessage/Sources/IMDatabase/Models/Attachment.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,11 @@ public extension Attachment {
3232
public static let recoverableError = Self(rawValue: 7)
3333
public static let rejected = Self(rawValue: 8)
3434
public static let thumbnail = Self(rawValue: 9)
35+
36+
/// A state the file will never recover from, so any waiter should give up
37+
/// rather than poll out its full timeout budget.
38+
public var isTerminalFailure: Bool {
39+
self == .error || self == .recoverableError || self == .rejected
40+
}
3541
}
3642
}

src/IMessage/Sources/IMDatabase/Models/MappedDatabaseRows.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ public struct MappedAttachmentRow: MappedDatabaseRow {
236236
public let filePath: String?
237237
public let size: [String: Int]?
238238

239+
public var transferStateValue: Attachment.IMFileTransferState? {
240+
transferState.map(Attachment.IMFileTransferState.init(rawValue:))
241+
}
242+
239243
public init(
240244
msgRowID: Int,
241245
filename: String?,

src/IMessage/Sources/IMessage/DatabaseTickWaits.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ private let sentMessageLinkWaitTimeout: TimeInterval = 1.5
1010
private let databaseTickBackstopInterval: TimeInterval = 1.0
1111
private let loadedAttachmentMinimumRequeryInterval: TimeInterval = 0.25
1212

13+
// todo: DatabaseTickWaits.{sentMessageIDs,sentThreadIDs} shouldn't exist, we get ServerEvents for new messages, use that
1314
enum DatabaseTickWaits {
1415
typealias SentMessageID = (rowID: Int, guid: String)
1516

src/IMessage/Sources/IMessage/EventWatcher/EventWatcher+Updates.swift

Lines changed: 155 additions & 88 deletions
Large diffs are not rendered by default.

src/IMessage/Sources/IMessage/EventWatcher/EventWatcher.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,15 @@ struct TimestampedChatState {
1414
}
1515
}
1616

17-
struct PendingLinkPreviewCandidate {
17+
enum PendingMessageHydrationKind {
18+
case linkPreview
19+
case attachmentLoad
20+
}
21+
22+
struct PendingMessageHydrationCandidate {
1823
let firstSeen: Date
1924
let chatGUID: String
25+
let kind: PendingMessageHydrationKind
2026
}
2127

2228
final class EventWatcher {
@@ -28,14 +34,14 @@ final class EventWatcher {
2834
var chatStates = [String: TimestampedChatState]()
2935
var updatesCursor: MessageUpdatesCursor
3036
var pendingUnresolvedNewMessageRowIDs = OrderedDictionary<Int, Date>()
31-
var pendingLinkPreviewCandidates = OrderedDictionary<Int, PendingLinkPreviewCandidate>()
37+
var pendingMessageHydrationCandidates = OrderedDictionary<Int, PendingMessageHydrationCandidate>()
3238
/// Rows resolved during the current tick whose pending entries are cleared
3339
/// only after the tick's events are successfully sent. On a send failure we
3440
/// leave them pending so the next tick retries: the main-query cursor has
3541
/// already advanced past these rows, so the pending maps are their only
3642
/// recovery path. Repopulated from scratch on every tick.
3743
var newMessageRowIDsAwaitingSendCommit: [Int] = []
38-
var linkPreviewRowIDsAwaitingSendCommit: [Int] = []
44+
var messageHydrationRowIDsAwaitingSendCommit: [Int] = []
3945
/// One-shot timer that re-triggers a tick while pending resolution work
4046
/// remains, so it isn't stranded when the database otherwise goes quiet.
4147
var pendingWakeTask: Task<Void, Never>?

src/IMessage/Sources/IMessage/PlatformAPI.swift

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -459,23 +459,9 @@ public final class PlatformAPI {
459459
let guid = messageGUID(fromID: messageID)
460460
guard let msgRow = try db.mappedMessageRow(guid: guid) else { return nil }
461461

462-
let terminalFailureStates: Set<Attachment.IMFileTransferState> = [
463-
.error,
464-
.recoverableError,
465-
.rejected,
466-
]
467-
468-
let attachmentRows: [MappedAttachmentRow] = try db.mappedAttachmentRows(messageRowIDs: [msgRow.rowID])
469-
470-
for attachmentRow in attachmentRows {
471-
guard let rawTransferState = attachmentRow.transferState else { continue }
472-
let transferState = Attachment.IMFileTransferState(rawValue: rawTransferState)
473-
if terminalFailureStates.contains(transferState) {
474-
return transferState
475-
}
476-
}
477-
478-
return nil
462+
return try db.mappedAttachmentRows(messageRowIDs: [msgRow.rowID])
463+
.compactMap(\.transferStateValue)
464+
.first { $0.isTerminalFailure }
479465
}
480466
}
481467

src/IMessage/Sources/IMessageTests/EventWatcherUpdateTests.swift

Lines changed: 170 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,17 +153,18 @@ import Testing
153153
text: "https://fixture.example.invalid/link"
154154
)
155155
let watcher = try fixtureEventWatcher(fixture: fixture, lastRowID: rowID)
156-
watcher.pendingLinkPreviewCandidates[rowID] = PendingLinkPreviewCandidate(
156+
watcher.pendingMessageHydrationCandidates[rowID] = PendingMessageHydrationCandidate(
157157
firstSeen: Date().addingTimeInterval(-121),
158-
chatGUID: fixture.chatGUID
158+
chatGUID: fixture.chatGUID,
159+
kind: .linkPreview
159160
)
160161

161162
// Payload never landed; the candidate is past its budget and must be
162163
// dropped without emitting a spurious update.
163164
let events = try await watcher.collectMessageUpdateEvents()
164165

165166
#expect(events.isEmpty)
166-
#expect(watcher.pendingLinkPreviewCandidates[rowID] == nil)
167+
#expect(watcher.pendingMessageHydrationCandidates[rowID] == nil)
167168
#expect(watcher.pendingWakeTask == nil)
168169
}
169170

@@ -228,6 +229,167 @@ import Testing
228229
#expect(thirdTickEvents.isEmpty)
229230
}
230231

232+
@Test func loadingAttachmentEmitsFullUpdateWhenTransferFinishes() async throws {
233+
let fixture = try TahoeChatDatabaseFixture()
234+
defer { fixture.cleanup() }
235+
236+
let rowID = 21
237+
let attachmentRowID = 210
238+
let messageGUID = "00000000-0000-4000-8000-000000000047"
239+
try insertJoinedMessage(fixture: fixture, rowID: rowID, guid: messageGUID, text: "")
240+
try fixture.insertAttachment(
241+
rowID: attachmentRowID,
242+
messageRowID: rowID,
243+
guid: "00000000-0000-4000-8000-000000000210",
244+
filename: "/tmp/fixture-loading.jpg",
245+
totalBytes: 10,
246+
transferState: Attachment.IMFileTransferState.transferring.rawValue
247+
)
248+
let watcher = try fixtureEventWatcher(fixture: fixture, lastRowID: rowID - 1)
249+
250+
let firstTickEvents = try await collectAndCommit(watcher)
251+
let firstEventObject = try firstMessageEventObject(in: firstTickEvents)
252+
let firstEntry = try firstMessageEntry(in: firstEventObject)
253+
let loadingAttachment = try firstAttachment(in: firstEntry)
254+
255+
#expect(firstEventObject["mutationType"] as? String == "upsert")
256+
#expect(loadingAttachment["loading"] as? Bool == true)
257+
#expect(watcher.pendingMessageHydrationCandidates[rowID] != nil)
258+
#expect(watcher.pendingWakeTask != nil)
259+
260+
try fixture.updateAttachmentTransferState(
261+
rowID: attachmentRowID,
262+
transferState: Attachment.IMFileTransferState.finished.rawValue
263+
)
264+
265+
let secondTickEvents = try await collectAndCommit(watcher)
266+
let secondEventObject = try firstMessageEventObject(in: secondTickEvents)
267+
let patch = try firstMessageEntry(in: secondEventObject)
268+
let loadedAttachment = try firstAttachment(in: patch)
269+
270+
#expect(secondTickEvents.count == 1)
271+
#expect(secondEventObject["mutationType"] as? String == "update")
272+
#expect(patch["id"] as? String == "\(messageGUID)_1")
273+
#expect(loadedAttachment["loading"] as? Bool == false)
274+
#expect(watcher.pendingMessageHydrationCandidates[rowID] == nil)
275+
#expect(watcher.pendingWakeTask == nil)
276+
}
277+
278+
@Test func pendingAttachmentLoadExpiresAfterTimeout() async throws {
279+
let fixture = try TahoeChatDatabaseFixture()
280+
defer { fixture.cleanup() }
281+
282+
let rowID = 22
283+
try insertJoinedMessage(fixture: fixture, rowID: rowID, guid: "00000000-0000-4000-8000-000000000048", text: "")
284+
try fixture.insertAttachment(
285+
rowID: 220,
286+
messageRowID: rowID,
287+
filename: "/tmp/fixture-never-loads.jpg",
288+
transferState: Attachment.IMFileTransferState.transferring.rawValue
289+
)
290+
let watcher = try fixtureEventWatcher(fixture: fixture, lastRowID: rowID)
291+
watcher.pendingMessageHydrationCandidates[rowID] = PendingMessageHydrationCandidate(
292+
firstSeen: Date().addingTimeInterval(-121),
293+
chatGUID: fixture.chatGUID,
294+
kind: .attachmentLoad
295+
)
296+
297+
let events = try await watcher.collectMessageUpdateEvents()
298+
299+
#expect(events.isEmpty)
300+
#expect(watcher.pendingMessageHydrationCandidates[rowID] == nil)
301+
#expect(watcher.pendingWakeTask == nil)
302+
}
303+
304+
@Test func multiAttachmentWaitsForAllTransfersBeforeUpdating() async throws {
305+
let fixture = try TahoeChatDatabaseFixture()
306+
defer { fixture.cleanup() }
307+
308+
let rowID = 23
309+
let messageGUID = "00000000-0000-4000-8000-000000000049"
310+
try insertJoinedMessage(fixture: fixture, rowID: rowID, guid: messageGUID, text: "")
311+
try fixture.insertAttachment(
312+
rowID: 230,
313+
messageRowID: rowID,
314+
guid: "00000000-0000-4000-8000-000000000230",
315+
filename: "/tmp/fixture-album-1.jpg",
316+
totalBytes: 10,
317+
transferState: Attachment.IMFileTransferState.transferring.rawValue
318+
)
319+
try fixture.insertAttachment(
320+
rowID: 231,
321+
messageRowID: rowID,
322+
guid: "00000000-0000-4000-8000-000000000231",
323+
filename: "/tmp/fixture-album-2.jpg",
324+
totalBytes: 10,
325+
transferState: Attachment.IMFileTransferState.transferring.rawValue
326+
)
327+
let watcher = try fixtureEventWatcher(fixture: fixture, lastRowID: rowID - 1)
328+
329+
// First tick: both attachments loading -> candidate tracked, wake armed.
330+
_ = try await collectAndCommit(watcher)
331+
#expect(watcher.pendingMessageHydrationCandidates[rowID] != nil)
332+
#expect(watcher.pendingWakeTask != nil)
333+
334+
// Only one attachment finishes -> message is not ready, so no event fires and
335+
// the candidate stays pending (guards against album flicker).
336+
try fixture.updateAttachmentTransferState(
337+
rowID: 230,
338+
transferState: Attachment.IMFileTransferState.finished.rawValue
339+
)
340+
let partialTickEvents = try await collectAndCommit(watcher)
341+
#expect(partialTickEvents.isEmpty)
342+
#expect(watcher.pendingMessageHydrationCandidates[rowID] != nil)
343+
#expect(watcher.pendingWakeTask != nil)
344+
345+
// The second attachment finishes -> exactly one full update, candidate cleared.
346+
try fixture.updateAttachmentTransferState(
347+
rowID: 231,
348+
transferState: Attachment.IMFileTransferState.finished.rawValue
349+
)
350+
let finalTickEvents = try await collectAndCommit(watcher)
351+
let finalEventObject = try firstMessageEventObject(in: finalTickEvents)
352+
353+
#expect(finalTickEvents.count == 1)
354+
#expect(finalEventObject["mutationType"] as? String == "update")
355+
#expect(watcher.pendingMessageHydrationCandidates[rowID] == nil)
356+
#expect(watcher.pendingWakeTask == nil)
357+
}
358+
359+
@Test func failedAttachmentTransferDropsCandidateWithoutEmitting() async throws {
360+
let fixture = try TahoeChatDatabaseFixture()
361+
defer { fixture.cleanup() }
362+
363+
let rowID = 24
364+
try insertJoinedMessage(fixture: fixture, rowID: rowID, guid: "00000000-0000-4000-8000-000000000050", text: "")
365+
try fixture.insertAttachment(
366+
rowID: 240,
367+
messageRowID: rowID,
368+
guid: "00000000-0000-4000-8000-000000000240",
369+
filename: "/tmp/fixture-fails.jpg",
370+
totalBytes: 10,
371+
transferState: Attachment.IMFileTransferState.transferring.rawValue
372+
)
373+
let watcher = try fixtureEventWatcher(fixture: fixture, lastRowID: rowID - 1)
374+
375+
// First tick: attachment loading -> candidate tracked, wake armed.
376+
_ = try await collectAndCommit(watcher)
377+
#expect(watcher.pendingMessageHydrationCandidates[rowID] != nil)
378+
#expect(watcher.pendingWakeTask != nil)
379+
380+
// Transfer fails terminally -> the candidate is dropped on the very next tick
381+
// (no event, wake disarmed) instead of polling for the full hydration timeout.
382+
try fixture.updateAttachmentTransferState(
383+
rowID: 240,
384+
transferState: Attachment.IMFileTransferState.error.rawValue
385+
)
386+
let events = try await collectAndCommit(watcher)
387+
388+
#expect(events.isEmpty)
389+
#expect(watcher.pendingMessageHydrationCandidates[rowID] == nil)
390+
#expect(watcher.pendingWakeTask == nil)
391+
}
392+
231393
@Test func readAndPreviewChangesForSameRowEmitOneFullUpdate() async throws {
232394
let fixture = try TahoeChatDatabaseFixture()
233395
defer { fixture.cleanup() }
@@ -442,3 +604,8 @@ private func firstLink(in messageObject: JSONObject) throws -> JSONObject {
442604
let links = try #require(messageObject["links"] as? [JSONObject])
443605
return try #require(links.first)
444606
}
607+
608+
private func firstAttachment(in messageObject: JSONObject) throws -> JSONObject {
609+
let attachments = try #require(messageObject["attachments"] as? [JSONObject])
610+
return try #require(attachments.first)
611+
}

src/IMessage/Sources/IMessageTests/TahoeChatDatabaseFixture.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,43 @@ final class TahoeChatDatabaseFixture {
106106
try database.execute(sqlWithoutEscaping: "UPDATE message SET payload_data = ? WHERE ROWID = ?", payloadData, rowID)
107107
}
108108

109+
func insertAttachment(
110+
rowID: Int,
111+
messageRowID: Int,
112+
guid: String? = nil,
113+
filename: String,
114+
transferName: String? = nil,
115+
totalBytes: Int = 0,
116+
transferState: Int
117+
) throws {
118+
try database.execute(
119+
sqlWithoutEscaping: """
120+
INSERT INTO attachment (ROWID, guid, original_guid, filename, transfer_name, total_bytes, transfer_state)
121+
VALUES (?, ?, ?, ?, ?, ?, ?)
122+
""",
123+
rowID,
124+
guid ?? "attachment-\(rowID)",
125+
"original-attachment-\(rowID)",
126+
filename,
127+
transferName ?? (filename as NSString).lastPathComponent,
128+
totalBytes,
129+
transferState
130+
)
131+
try database.execute(
132+
sqlWithoutEscaping: "INSERT INTO message_attachment_join (message_id, attachment_id) VALUES (?, ?)",
133+
messageRowID,
134+
rowID
135+
)
136+
}
137+
138+
func updateAttachmentTransferState(rowID: Int, transferState: Int) throws {
139+
try database.execute(
140+
sqlWithoutEscaping: "UPDATE attachment SET transfer_state = ? WHERE ROWID = ?",
141+
transferState,
142+
rowID
143+
)
144+
}
145+
109146
private static func insertChatJoin(
110147
messageRowID: Int,
111148
chatRowID: Int,

todos.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
- [ ] separate `UserDefaults` somehow so that CLI and other consumers don't share the prefs
1818
- [ ] user manually killing the messages.app causes cli to not detect that ("Domain=NSOSStatusErrorDomain Code=-600 "procNotFound: no eligible process with specified descriptor"")
1919
- [ ] when scheduled messages are actually sent, send a message update event
20+
- [ ] surface terminal-failure attachment state so a failed/rejected download clears the loading spinner. Today `loading = transferState != .finished` (`MessageMapper+Attachments.swift:26`), so error/recoverableError/rejected (states 6/7/8, `Attachment.swift:31-33`) read as `loading: true` forever; `PlatformSDK.Attachment` (`PlatformSDKAttachment.swift:26`) has no failed flag. After the EventWatcher hydration path stops polling failed transfers, the last emitted state still says loading, so the consumer spins indefinitely. Needs: a failed/error field on the attachment type + mapper wiring + EventWatcher emitting it on terminal failure + consumer-side rendering. The EventWatcher hydration path already drops terminally-failed candidates via `IMFileTransferState.isTerminalFailure`.
2021
- [ ] perhaps move PlatformSDK to <https://github.com/TextsHQ/platform-sdk>
2122

2223
- concurrency

0 commit comments

Comments
 (0)