Skip to content

use granular state sync events for changes instead of thread messages refresh#69

Open
KishanBagaria wants to merge 46 commits intomainfrom
kb/state-sync
Open

use granular state sync events for changes instead of thread messages refresh#69
KishanBagaria wants to merge 46 commits intomainfrom
kb/state-sync

Conversation

@KishanBagaria
Copy link
Copy Markdown
Member

@KishanBagaria KishanBagaria commented May 3, 2026

when we see a changes in the message table in chat.db, we send a thread_messages_refresh event which is asking the consumers to refetch messages for the updated chat.

instead let's be granular and emit a proper event saying exactly what has changed.

a change could be:

  1. a new incoming/outgoing message (new row)
  2. an edited message (existing row updated)
  3. an existing message is seen (existing row updated)
  4. undo sent message (existing row updated)
  5. a deleted message (existing row deleted/moved to another table)
  6. a reaction (new row "X liked Y" + deleted row for the last reaction/reaction removal – imessage only lets you react to a message with one reaction)
  7. a reaction removal (new row "X removed a like from Y" + deleted row for the last reaction/reaction removal)

how the above changes should state synced:

  1. message upsert for new messages
  2. message update for edits
  3. message update for message reads
  4. message update for undo sends
  5. not in scope for this PR
  6. message_reaction upsert for reaction, message upsert for the reaction row ("X liked Y"), potential message delete for the last reaction row
  7. message_reaction delete for reaction, message upsert for the reaction removal row ("X removed a like from Y"), potential message delete for the last reaction row

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 3, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Shift incremental sync from chat-level to message-level: add batched DB lookups and rowID-keyed message mapping, detect per-message new/read/edited flags, parse typed associated/reaction data, emit message-level ServerEvent upsert/update/delete; JS revives numeric timestamp/seen fields into Date objects.

Changes

Message-Level State Sync

Layer / File(s) Summary
Data Shape
src/IMessage/Sources/IMDatabase/Models/MappedDatabaseRows.swift, src/IMessage/Sources/IMDatabase/Models/MappedDatabaseRows+DictionaryBridges.swift
MappedMessageRow adds optional replyToGUID and includes "reply_to_guid" in the dictionary bridge.
DB Query / Batch Access
src/IMessage/Sources/IMDatabase/Database/IMDatabase+MappedMessages.swift, src/IMessage/Sources/IMessageCore/Array+Chunks.swift
Added mappedMessageRows(guids:) with batching (chunks via Array.chunks(ofCount:)) and maxMappedMessageRowsBatchSize = 500; mappedMessageRow(guid:) delegates to it. mappedMessageRows(rowIDs:) also batches and sorts combined results. mappedReactionRows(...) SQL now ORDER BY m.ROWID ASC.
DB Incremental Changes
src/IMessage/Sources/IMDatabase/Database/IMDatabase+Updates.swift
Replaced chat-aggregated query with messages(newerThanRowID:orReadSince:orEditedSince:) returning UpdatedMessagesQueryResult and per-message UpdatedMessageChange rows; query ordered by m.ROWID ASC and maxima update only when per-message flags are true.
Message Mapping by RowID
src/IMessage/Sources/IMessage/Hashing/PlatformAPI+Hashing.swift, src/IMessage/Sources/IMessage/PlatformAPI.swift
Added mapAndHashMessagesByRowID(...) returning [rowID: [Message]]; mapAndHashMessages(...) now delegates to it. messagePayloadRows(...) changed to nonisolated static.
Associated / Reaction Types & Parsing
src/IMessage/Sources/IMessage/Mappers/MessageMapperTypes.swift, src/IMessage/Sources/IMessage/Mappers/MessageMapper+Associated.swift
Introduced AssociatedMessageTarget / parseAssociatedMessageTarget, AssociatedReaction/ReactionAction/AssociatedReactionKey, and AssociatedMessageType. Message mapper refactored to use typed reaction handling; legacy string-splitting helper removed.
EventWatcher Update Pipeline
src/IMessage/Sources/IMessage/EventWatcher/EventWatcher+Updates.swift
collectMessageUpdateEvents() now reads message-level diffs from db.messages(...), advances the cursor using returned maxima, and builds message-level upserts/patches/deletes. Added helpers: messageUpdateEvents(for:), messageUpdatePatch(...), reaction-target patch generation, and deduplicating/merging update patches.
EventWatcher Context & Lifecycle
src/IMessage/Sources/IMessage/EventWatcher/EventWatcher.swift, src/IMessage/Sources/IMessage/EventWatcher/EventWatcherLifecycle.swift
EventWatcher stores currentUserID and now requires accountID at init. EventWatcherLifecycle.subscribeToEvents requires accountID and stores it in Subscription; startWatching passes accountID into EventWatcher.
ServerEvent Serialization
src/IMessage/Sources/PlatformSDK/ServerEvent.swift
Added upsertMessages, updateMessages, deleteMessages cases and JSON serialization producing state_sync payloads with objectName: "message" and appropriate mutationType and entries.
TypeScript: Swift JSON Revival & Tests
src/swift-json.ts, src/swift-json.test.ts
Expanded SWIFT_DATE_FIELDS, added isMutableRecord, enhanced swiftMapperReviver to convert numeric seen object leaves into Dates, added reviveSwiftMessageAPIValue<T> to revive parsed payloads in-place, and added tests verifying nested-date conversion and in-place mutation.
API Wiring (consumer)
src/IMessage/Sources/IMessage/PlatformAPI.swift, src/api.ts
subscribeToEvents forwards labeled accountID to lifecycle. reactionMessageGUID(_:) uses parseAssociatedMessageTarget. JS subscribeToEvents now calls reviveSwiftMessageAPIValue(event) for non-TOAST events before delivering them to subscribers.
Docs / Todos
todos.md
Moved prior "instead of thread_messages_refresh" checklist items into Done (checked).

Sequence Diagram(s)

sequenceDiagram
    participant Watcher as EventWatcher
    participant DB as IMDatabase
    participant API as PlatformAPI
    participant Sender as ServerEventSender
    participant Client as JS Client

    Watcher->>DB: messages(newerThanRowID:/orReadSince:/orEditedSince:)
    DB-->>Watcher: UpdatedMessagesQueryResult (rows + latest maxima)
    Watcher->>API: mapAndHashMessagesByRowID(db, msgRows, currentUserID, accountID)
    API-->>Watcher: [rowID: [Message]]
    Watcher->>Watcher: build upserts / patches / deletes (dedupe/merge)
    Watcher->>Sender: emit ServerEvent.upsert/update/deleteMessages
    Sender-->>Client: state_sync JSON
    Client->>Client: reviveSwiftMessageAPIValue(event)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive No description was provided; the PR lacks any explanatory context about the changes. Add a description explaining what problems this change solves and how the state sync events improve the message update mechanism.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: replacing thread message refresh logic with granular state sync events for message updates.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch kb/state-sync

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
src/swift-json.test.ts (1)

4-44: ⚡ Quick win

Add assertions for the newly added date fields.

Current tests don’t validate createdAt, mutedUntil, and lastActive, which are now part of revival logic.

Proposed diff
   it('converts event date fields in already-parsed Swift values', () => {
     const revived = reviveSwiftMessageAPIValue({
       entries: [{
         id: 'message-id',
+        createdAt: 5,
+        mutedUntil: 6,
+        lastActive: 7,
         timestamp: 1,
         editedTimestamp: 2,
         seen: {
           alice: 3,
           bob: true,
@@
     expect(revived.entries[0].timestamp).toEqual(new Date(1))
     expect(revived.entries[0].editedTimestamp).toEqual(new Date(2))
+    expect(revived.entries[0].createdAt).toEqual(new Date(5))
+    expect(revived.entries[0].mutedUntil).toEqual(new Date(6))
+    expect(revived.entries[0].lastActive).toEqual(new Date(7))
     expect(revived.entries[0].seen).toEqual({
       alice: new Date(3),
       bob: true,
     })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/swift-json.test.ts` around lines 4 - 44, Add assertions that the new date
fields are correctly revived: in the parseSwiftMessageAPIJSON test assert
parsed.createdAt and parsed.mutedUntil and parsed.lastActive are Date objects
equal to new Date(<input>) (or remain primitives where appropriate), and in the
reviveSwiftMessageAPIValue test assert revived.entries[0].createdAt,
revived.entries[0].mutedUntil, and revived.entries[0].lastActive are converted
to Date instances (e.g., new Date(…)) or preserve non-numeric values as
expected; update the sample inputs in both tests to include numeric values for
createdAt/mutedUntil/lastActive so the assertions validate the revival logic in
parseSwiftMessageAPIJSON and reviveSwiftMessageAPIValue.
src/IMessage/Sources/IMDatabase/Database/IMDatabase+MappedMessages.swift (1)

141-154: ⚡ Quick win

Make bulk GUID fetch order deterministic.

Line 149 uses IN (...) without ORDER BY, so row order is undefined. Since Line 138 takes .first, this can become non-deterministic on edge datasets. Add an explicit order.

Proposed diff
 func mappedMessageRows(guids: [String]) throws -> [MappedMessageRow] {
     guard !guids.isEmpty else { return [] }
     let messageColumns = try tableColumns("message")
@@
     let sql = """
     SELECT
     \(messageSelectionSQL(messageColumns: messageColumns))
     FROM message AS m
     \(messageJoins)
     WHERE m.guid IN (\(placeholders(count: guids.count)))
+    ORDER BY m.ROWID ASC
     """
     let statement = try Statement.prepare(escapedSQL: sql, for: database)
     try statement.bind(guids.map { $0 as any SQLiteBindable })
     return try statement.mapRowsUntilDone(MappedMessageRow.self)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/IMessage/Sources/IMDatabase/Database/IMDatabase`+MappedMessages.swift
around lines 141 - 154, The query in mappedMessageRows(guids:) uses WHERE m.guid
IN (...) which returns rows in an undefined order; to make results deterministic
and match the input guids, append an ORDER BY that preserves the input guids
order (e.g. "ORDER BY CASE m.guid WHEN 'guid1' THEN 1 WHEN 'guid2' THEN 2 ...
ELSE N END" built from the guids array) instead of relying on unspecified
ordering; update the SQL string construction (the sql local), ensure the CASE
entries are generated for each guids element, and adjust the parameters bound to
the Statement (used in try statement.bind(...)) so the guid values are provided
both for the IN clause and, if you use placeholders in the CASE, for those
placeholders as well, then return the mapped rows as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/IMessage/Sources/IMessage/EventWatcher/EventWatcher`+Updates.swift:
- Around line 108-110: The code appends raw replyToGUID into deletesByThreadID
which leaks internal GUIDs and breaks delete semantics; instead map/hash
replyToGUID using the same GUID→public ID path used when creating message.id and
append that mapped ID. Replace the direct append in EventWatcher+Updates (the
block referencing replyToGUID, deletesByThreadID, and hashedThreadID(for:)) to
call the existing mapping/hashing utility used by normal message mapping (the
function or logic that sets message.id) and use its result when appending delete
IDs.
- Around line 43-50: The code advances updatesCursor to newCursor immediately
after computing MessageUpdatesCursor (constructed with
lastRowID/lastDateRead/lastDateEdited), which causes lost updates if
sender(events) later fails; instead remove the assignment updatesCursor =
newCursor from this computation path and only set updatesCursor = newCursor
after the send completes successfully (i.e., in the path that performs try await
sender(events) in EventWatcher/EventWatcher+Updates send logic), so that
updatesCursor is committed only on successful delivery; update references in
MessageUpdatesCursor creation and the send flow to assign the cursor post-sender
success and ensure the error/catch path does not mutate updatesCursor.

---

Nitpick comments:
In `@src/IMessage/Sources/IMDatabase/Database/IMDatabase`+MappedMessages.swift:
- Around line 141-154: The query in mappedMessageRows(guids:) uses WHERE m.guid
IN (...) which returns rows in an undefined order; to make results deterministic
and match the input guids, append an ORDER BY that preserves the input guids
order (e.g. "ORDER BY CASE m.guid WHEN 'guid1' THEN 1 WHEN 'guid2' THEN 2 ...
ELSE N END" built from the guids array) instead of relying on unspecified
ordering; update the SQL string construction (the sql local), ensure the CASE
entries are generated for each guids element, and adjust the parameters bound to
the Statement (used in try statement.bind(...)) so the guid values are provided
both for the IN clause and, if you use placeholders in the CASE, for those
placeholders as well, then return the mapped rows as before.

In `@src/swift-json.test.ts`:
- Around line 4-44: Add assertions that the new date fields are correctly
revived: in the parseSwiftMessageAPIJSON test assert parsed.createdAt and
parsed.mutedUntil and parsed.lastActive are Date objects equal to new
Date(<input>) (or remain primitives where appropriate), and in the
reviveSwiftMessageAPIValue test assert revived.entries[0].createdAt,
revived.entries[0].mutedUntil, and revived.entries[0].lastActive are converted
to Date instances (e.g., new Date(…)) or preserve non-numeric values as
expected; update the sample inputs in both tests to include numeric values for
createdAt/mutedUntil/lastActive so the assertions validate the revival logic in
parseSwiftMessageAPIJSON and reviveSwiftMessageAPIValue.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0f590679-a190-40df-ad5f-ee9c0511c664

📥 Commits

Reviewing files that changed from the base of the PR and between c2f552e and 6233fad.

📒 Files selected for processing (14)
  • src/IMessage/Sources/IMDatabase/Database/IMDatabase+MappedMessages.swift
  • src/IMessage/Sources/IMDatabase/Database/IMDatabase+Updates.swift
  • src/IMessage/Sources/IMDatabase/Models/MappedDatabaseRows+DictionaryBridges.swift
  • src/IMessage/Sources/IMDatabase/Models/MappedDatabaseRows.swift
  • src/IMessage/Sources/IMessage/EventWatcher/EventWatcher+Updates.swift
  • src/IMessage/Sources/IMessage/EventWatcher/EventWatcher.swift
  • src/IMessage/Sources/IMessage/EventWatcher/EventWatcherLifecycle.swift
  • src/IMessage/Sources/IMessage/Mappers/MessageMapper+Associated.swift
  • src/IMessage/Sources/IMessage/Mappers/MessageMapperTypes.swift
  • src/IMessage/Sources/IMessage/PlatformAPI.swift
  • src/IMessage/Sources/PlatformSDK/ServerEvent.swift
  • src/api.ts
  • src/swift-json.test.ts
  • src/swift-json.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🧰 Additional context used
🪛 SwiftLint (0.63.2)
src/IMessage/Sources/IMessage/Mappers/MessageMapperTypes.swift

[Warning] 32-32: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 38-38: Magic numbers should be replaced by named constants

(no_magic_numbers)

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

[Warning] 53-53: Function should have complexity 12 or less; currently complexity is 17

(cyclomatic_complexity)


[Warning] 53-53: Function body should span 80 lines or less excluding comments and whitespace: currently spans 101 lines

(function_body_length)


[Warning] 295-295: Prefer not to use extension access modifiers

(no_extension_access_modifier)

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

[Warning] 90-90: Magic numbers should be replaced by named constants

(no_magic_numbers)

🔇 Additional comments (12)
src/IMessage/Sources/IMDatabase/Models/MappedDatabaseRows.swift (1)

122-124: Reply linkage mapping looks correct.

reply_to_guid is wired into both the model and row decoding path consistently.

Also applies to: 172-172

src/IMessage/Sources/IMDatabase/Models/MappedDatabaseRows+DictionaryBridges.swift (1)

31-31: Dictionary bridge remains in sync with the new message field.

The reply_to_guid value is now correctly round-tripped in both directions.

Also applies to: 73-73

src/IMessage/Sources/IMessage/Mappers/MessageMapperTypes.swift (1)

11-34: Shared associated-message parsing looks solid.

Nice consolidation: the parser and reactionParts helper centralize behavior used across mapping paths.

Also applies to: 36-42

src/IMessage/Sources/IMessage/Mappers/MessageMapper+Associated.swift (1)

26-26: Good move to shared linked-message parsing.

Using parseAssociatedMessageTarget here reduces drift across reaction/association code paths.

src/IMessage/Sources/IMDatabase/Database/IMDatabase+MappedMessages.swift (1)

252-252: Nice addition of explicit reaction-row ordering.

The ORDER BY m.ROWID ASC makes reaction processing deterministic.

src/IMessage/Sources/PlatformSDK/ServerEvent.swift (1)

38-46: Message-level state sync serialization is cleanly structured.

The new cases and shared helper keep payload construction consistent across upsert/update/delete.

Also applies to: 102-119, 123-131

src/swift-json.ts (1)

1-8: Nested Swift payload revival implementation looks good.

The new recursive helper and seen object handling match the intended event-shape normalization.

Also applies to: 10-34

src/IMessage/Sources/IMDatabase/Database/IMDatabase+Updates.swift (1)

43-133: Message-level update query/result flow looks solid.

Good change moving to per-message deltas with monotonic cursor maxima computation in one pass.

src/IMessage/Sources/IMessage/EventWatcher/EventWatcher.swift (1)

25-45: Initializer context wiring is consistent.

Capturing both currentUserID and accountID at construction aligns with the new message mapping flow.

src/api.ts (1)

110-115: Good event normalization before forwarding.

Reviving non-TOAST events before onEvent is the right place to enforce Swift JSON value restoration.

src/IMessage/Sources/IMessage/PlatformAPI.swift (1)

120-124: accountID propagation into lifecycle is correctly wired.

This matches the updated lifecycle/watcher contract and keeps event mapping context consistent.

src/IMessage/Sources/IMessage/EventWatcher/EventWatcherLifecycle.swift (1)

92-105: Lifecycle guard + constructor handoff look correct.

Requiring accountID before startWatching is a good safety check for the new watcher contract.

Comment thread src/IMessage/Sources/IMessage/EventWatcher/EventWatcher+Updates.swift Outdated
Comment thread src/IMessage/Sources/IMessage/EventWatcher/EventWatcher+Updates.swift Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
src/IMessage/Sources/IMessage/EventWatcher/EventWatcher+Updates.swift (2)

37-44: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Commit updatesCursor only after event delivery succeeds.

This function advances updatesCursor before the caller has actually delivered events. If delivery fails afterward, the next poll starts past these rows and the updates are lost.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/IMessage/Sources/IMessage/EventWatcher/EventWatcher`+Updates.swift around
lines 37 - 44, Currently the code constructs MessageUpdatesCursor (newCursor)
and assigns it to updatesCursor before returning events, which loses updates if
delivery fails; change this so you compute newCursor but do not set the shared
updatesCursor inside this method. Instead return the newCursor alongside events
(e.g., change the method to return (events, newCursor) or a wrapper type) or
expose a commit method (e.g., commitMessageUpdatesCursor(_:)) that the caller
invokes after successful delivery; update usages accordingly and keep
traceMessageUpdates logging but only set updatesCursor when the caller confirms
delivery.

89-93: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Map replyToGUID through the public message-ID path before deleting.

Line 92 bypasses the normal message mapping path and emits a DB GUID directly into deleteMessages. That can diverge from the ID format used when the hidden reaction-add message was originally upserted, and it also leaks internal IDs upstream.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/IMessage/Sources/IMessage/EventWatcher/EventWatcher`+Updates.swift around
lines 89 - 93, Currently the code appends the internal replyToGUID directly into
deletesByThreadID which bypasses the public message-ID mapping and can leak
internal DB GUIDs; instead, pass replyToGUID through the existing public
message-ID conversion used elsewhere (the same mapper used when upserting hidden
reaction-add messages) and append that public ID to deletesByThreadID (keep the
Hasher.thread.tokenizeRemembering(originalThreadID) usage for the thread key).
Replace the direct use of replyToGUID with the mapped/public ID before adding to
deletesByThreadID.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/IMessage/Sources/IMDatabase/Database/IMDatabase`+MappedMessages.swift:
- Around line 143-149: The chunked path in mappedMessageRows(guids:) can
re-query duplicate GUIDs across chunks causing duplicate rows; before checking
guids.count <= maxMappedMessageRowsBatchSize or calling guids.chunks(...)
normalize the input by deduplicating the guids array (e.g., create a unique
list/preserve order) and then use that deduped list for the size check and
subsequent chunked calls to mappedMessageRows(guids: Array(chunk)) so both
chunked and non-chunked paths behave consistently.

In `@src/IMessage/Sources/IMessage/Mappers/MessageMapper`+Associated.swift:
- Around line 120-127: The SMS tapback fallback is being hidden and converted
into a structured reaction; update the block that sets message.action and
message.isHidden so it only assigns a structured action and hides the message
when the message is an actionable reaction (i.e., message.isAction is true);
otherwise (SMS fallback) do not set message.action, leave message.isHidden
false, and keep the visible fallback text (constructed with msgRow.isFromMe and
summaryInfo.string("ams")) so SMS reactions render as visible "Liked …" style
messages instead of hidden metadata.

---

Duplicate comments:
In `@src/IMessage/Sources/IMessage/EventWatcher/EventWatcher`+Updates.swift:
- Around line 37-44: Currently the code constructs MessageUpdatesCursor
(newCursor) and assigns it to updatesCursor before returning events, which loses
updates if delivery fails; change this so you compute newCursor but do not set
the shared updatesCursor inside this method. Instead return the newCursor
alongside events (e.g., change the method to return (events, newCursor) or a
wrapper type) or expose a commit method (e.g., commitMessageUpdatesCursor(_:))
that the caller invokes after successful delivery; update usages accordingly and
keep traceMessageUpdates logging but only set updatesCursor when the caller
confirms delivery.
- Around line 89-93: Currently the code appends the internal replyToGUID
directly into deletesByThreadID which bypasses the public message-ID mapping and
can leak internal DB GUIDs; instead, pass replyToGUID through the existing
public message-ID conversion used elsewhere (the same mapper used when upserting
hidden reaction-add messages) and append that public ID to deletesByThreadID
(keep the Hasher.thread.tokenizeRemembering(originalThreadID) usage for the
thread key). Replace the direct use of replyToGUID with the mapped/public ID
before adding to deletesByThreadID.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d87dfa75-356f-4791-b016-2d25e8e17854

📥 Commits

Reviewing files that changed from the base of the PR and between 3bac14d and 41bc82f.

📒 Files selected for processing (8)
  • src/IMessage/Sources/IMDatabase/Database/IMDatabase+MappedMessages.swift
  • src/IMessage/Sources/IMessage/EventWatcher/EventWatcher+Updates.swift
  • src/IMessage/Sources/IMessage/Hashing/PlatformAPI+Hashing.swift
  • src/IMessage/Sources/IMessage/Mappers/MessageMapper+Associated.swift
  • src/IMessage/Sources/IMessage/Mappers/MessageMapperTypes.swift
  • src/IMessage/Sources/IMessageCore/Array+Chunks.swift
  • src/swift-json.test.ts
  • src/swift-json.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/swift-json.test.ts
  • src/swift-json.ts
📜 Review details
🧰 Additional context used
🪛 SwiftLint (0.63.2)
src/IMessage/Sources/IMessageCore/Array+Chunks.swift

[Warning] 1-1: Prefer not to use extension access modifiers

(no_extension_access_modifier)

src/IMessage/Sources/IMessage/Mappers/MessageMapperTypes.swift

[Warning] 32-32: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 165-165: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 166-166: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 167-167: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 168-168: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 169-169: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 170-170: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 171-171: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 172-172: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 173-173: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 174-174: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 175-175: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 176-176: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 177-177: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 178-178: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 179-179: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 180-180: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 181-181: Magic numbers should be replaced by named constants

(no_magic_numbers)


[Warning] 182-182: Magic numbers should be replaced by named constants

(no_magic_numbers)

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

[Warning] 47-47: Function should have complexity 12 or less; currently complexity is 18

(cyclomatic_complexity)


[Warning] 47-47: Function body should span 80 lines or less excluding comments and whitespace: currently spans 89 lines

(function_body_length)

🔇 Additional comments (2)
src/IMessage/Sources/IMessageCore/Array+Chunks.swift (1)

2-6: Chunking implementation looks correct and safe.

chunks(ofCount:) handles invalid sizes (Line 3) and correctly bounds the last slice (Line 5).

src/IMessage/Sources/IMDatabase/Database/IMDatabase+MappedMessages.swift (1)

266-266: Deterministic reaction row ordering is a good improvement.

Adding ORDER BY m.ROWID ASC on Line 266 helps keep reaction processing stable across runs.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/swift-json.ts (1)

39-41: ⚡ Quick win

Make array handling recurse through full event envelopes.

On Line 39, array elements are treated as flat entries only. If callers pass an array of event envelopes, nested entries/presence won’t be revived in this branch.

Proposed diff
   if (Array.isArray(value)) {
-    value.forEach(reviveSwiftEventEntry)
+    value.forEach(item => {
+      reviveSwiftMessageAPIValue(item)
+    })
     return value
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/swift-json.ts` around lines 39 - 41, The array branch in revive logic
treats each item as a flat entry by calling reviveSwiftEventEntry on every
element, so if callers pass full event envelopes those nested entries/presence
fields are never revived; update the Array.isArray branch to iterate items and
for each item call reviveSwiftEventEnvelope when the object looks like a full
envelope (e.g., has entries or presence keys) and otherwise call
reviveSwiftEventEntry, ensuring nested entries/presence are processed by
reviveSwiftEventEnvelope (refer to reviveSwiftEventEntry and
reviveSwiftEventEnvelope to locate the relevant logic).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/swift-json.ts`:
- Around line 39-41: The array branch in revive logic treats each item as a flat
entry by calling reviveSwiftEventEntry on every element, so if callers pass full
event envelopes those nested entries/presence fields are never revived; update
the Array.isArray branch to iterate items and for each item call
reviveSwiftEventEnvelope when the object looks like a full envelope (e.g., has
entries or presence keys) and otherwise call reviveSwiftEventEntry, ensuring
nested entries/presence are processed by reviveSwiftEventEnvelope (refer to
reviveSwiftEventEntry and reviveSwiftEventEnvelope to locate the relevant
logic).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9024aeaa-44ab-4542-b18b-eac23efd02f5

📥 Commits

Reviewing files that changed from the base of the PR and between 192df7f and a5e85a8.

📒 Files selected for processing (3)
  • src/IMessage/Sources/IMessage/Hashing/PlatformAPI+Hashing.swift
  • src/swift-json.ts
  • todos.md
✅ Files skipped from review due to trivial changes (1)
  • todos.md
📜 Review details
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-05-03T17:00:19.662Z
Learnt from: KishanBagaria
Repo: beeper/platform-imessage PR: 69
File: src/IMessage/Sources/IMessage/EventWatcher/EventWatcher+Updates.swift:89-93
Timestamp: 2026-05-03T17:00:19.662Z
Learning: In the beeper/platform-imessage Swift codebase, keep message IDs (`PlatformSDK.MessageID`) as raw GUIDs. When mapping from DB/event rows to `message.id`, set the ID directly from `msgRow.guid` (no GUID→public-ID hashing or transformation). For multi-part messages, append the part index as `_<part.index>` to the GUID-derived ID. During code review, if changes touch message ID creation/mapping, ensure this raw GUID + optional `_<part.index>` suffix behavior is preserved.

Applied to files:

  • src/IMessage/Sources/IMessage/Hashing/PlatformAPI+Hashing.swift
🔇 Additional comments (2)
src/IMessage/Sources/IMessage/Hashing/PlatformAPI+Hashing.swift (1)

40-76: LGTM — the new rowID lookup matches the caller’s needs.

This refactor keeps mapAndHashMessages behavior intact while exposing the rowID-keyed helper expected by PlatformAPI.swift. I don’t see any correctness issues here, and the raw GUID message-ID behavior remains unchanged.

src/swift-json.ts (1)

13-22: Good key-aware revival for seen participant timestamps.

The key === 'seen' handling on Line 15 correctly revives numeric per-participant values without touching non-numeric leaves. Nice targeted fix.

@KishanBagaria KishanBagaria requested a review from pmanot May 3, 2026 20:30
@KishanBagaria KishanBagaria marked this pull request as ready for review May 5, 2026 12:59
Copilot AI review requested due to automatic review settings May 5, 2026 12:59
@indent
Copy link
Copy Markdown

indent Bot commented May 5, 2026

PR Summary

Replaces the iMessage event watcher's coarse thread_messages_refresh events with fine-grained state_sync message/message_reaction events so renderers can apply targeted upserts/updates/deletes instead of re-fetching whole threads. The watcher now emits typed upsertMessages, updateMessages, deleteMessages, upsertMessageReactions, and deleteMessageReactions events, gated by a new DB-existence check that suppresses spurious deletes when the prior hidden reaction-action row still exists.

  • Replaces chats(withMessagesNewerThanRowID:...) with messages(since: MessageUpdatesCursor) (lastRowID/lastDateRead/lastDateEdited), captured atomically via messageUpdateCursorSnapshot().
  • Refactors associated-message handling into typed AssociatedMessageType/AssociatedReaction enums and a single parseAssociatedMessageTarget helper that normalizes both p:N/ and bp: prefixes.
  • Adds MessageUpdateEventDatabase protocol + IMDatabase.existingMessageGUIDs(among:) (chunked SQL) so the previous-reaction-action delete only fires when iMessage has actually removed the prior hidden row.
  • Adds chunked, dedup-aware mappedMessageRows(guids:) / mappedMessageRows(rowIDs:) (500-row batches via new Array.chunks(ofCount:)).
  • Adds TS-side reviveSwiftMessageAPIValue to walk already-parsed Swift bridge values and convert numeric date fields to JS Dates; adds createdAt, lastActive, mutedUntil to the date-field set.
  • Adds MessageEventFixtureTests.swift (293 lines) + 6 fixture JSONs covering edit, new-incoming, new-outgoing, reaction-add, reaction-remove, undo-send, plus two dedicated tests for both branches of the new previous-action DB gate.
  • Hashes the userActivity event's threadID (was previously sent unhashed) and replaces the ChatRef enum with raw chat-GUID strings throughout the unread/event-watcher path.

Issues

4 potential issues found:

  • 70c391e added MessageEventFixtureTests.swift (293 lines) plus 6 fixture JSONs covering the reaction add/remove flow, the new previous-action DB-existence gate (both branches), edit, undo-send, and new-message paths. Significant progress, but several branches remain uncovered: multi-row batches (so multi-chat routing and reaction batching across rows are untested), deduplicatedUpdatePatches, edit-dominates-read priority, isNew && wasRead redundancy, and the replyToGUID normalization edge case where reply_to_guid is prefixed. → Autofix
  • previousReactionActionCandidateGUID (now lines 301-314) returns replyToGUID without normalizing it, then compares it bare against target.messageGUID/target.messageID. 70c391e added a DB-existence gate that incidentally protects against the worst case (a malformed prefixed string won't match the renderer's bare-GUID format so it can't accidentally delete the original target), but the underlying normalization is still missing — if Apple ever stores reply_to_guid as p:N/<UUID> or bp:<UUID>, the equality guards no-op and a malformed ID is propagated through existingMessageGUIDs, ends up in missingPreviousReactionActionMessageIDs, and is emitted as a spurious deleteMessages ID. → Autofix
  • When a row is both isNew=true and wasRead=true (e.g., backdated / iCloud-synced messages), the loop at lines 128-134 emits both an upsertMessages (full row) and an updateMessages "read" patch (subset of the same fields) for the same message in the same batch — the read patch is wasted bytes/events. Same redundancy applies to isNew=true && wasEdited=true because MessageUpdateKind.edited.patch(for:) returns message.jsonObject (the full message). Cheap to skip the update when change.isNew is already true. Still present after 70c391e. → Autofix
  • Multi-chat messages route to only one chat: let threadID = msgRow.threadID ?? change.chatGUID (now line 68) always picks msgRow.threadID because it is non-nil for any non-orphaned message, so a message present in chats A and B gets all events keyed to whichever chat the rowID-dedup at line 169 happened to keep — the other chat receives nothing (and its reactions get duplicated into the dedup-winner). Old code's GROUP BY c.guid emitted refresh events for every affected chat. Still present after 70c391e — the new DB-gate refactor preserved the routing logic verbatim, and the new fixture tests use a single chat per fixture so this path remains uncovered. → Autofix

CI Checks

Waiting for CI checks...


⚡ Autofix All Issues

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR replaces the coarse thread_messages_refresh event (which forced consumers to refetch an entire thread) with granular state_sync events for message upserts/updates/deletes and message reaction upserts/deletes, based on incremental tracking of message table changes in chat.db.

Changes:

  • Introduces message- and reaction-scoped state_sync server events and updates the event watcher to emit those instead of thread-wide refreshes.
  • Adds a DB-level MessageUpdatesCursor snapshot + incremental “messages since cursor” query to drive update detection.
  • Updates JSON date revival on the Node side for Swift-bridge payloads, with accompanying tests.

Reviewed changes

Copilot reviewed 25 out of 25 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
todos.md Marks the granular state-sync migration tasks as completed.
src/swift-json.ts Expands Swift JSON date-field revival (including nested seen maps) and adds an in-place reviver for already-parsed bridge objects.
src/swift-json.test.ts Adds unit tests covering JSON parsing revival and in-place revival mutation behavior.
src/IMessage/Sources/PlatformSDK/ServerEvent.swift Adds new message/message_reaction state_sync event cases and JSON encoding helpers; deprecates thread_messages_refresh.
src/IMessage/Sources/IMessageTests/HashingTests.swift Adds a test verifying thread-ID tokenization/recovery behavior.
src/IMessage/Sources/IMessageCore/Array+Chunks.swift Adds a chunking helper used for batching large IN (...) queries.
src/IMessage/Sources/IMessage/PlatformAPI.swift Switches event watcher startup to use the new cursor snapshot + current user ID; refactors latest-message mapping to reuse new helpers.
src/IMessage/Sources/IMessage/Mappers/MessageMapperTypes.swift Refactors associated-message parsing (incl. part IDs) and reaction typing into structured enums/types.
src/IMessage/Sources/IMessage/Mappers/MessageMapper+Associated.swift Refactors reaction/action mapping to use new associated-message parsing + shared helpers.
src/IMessage/Sources/IMessage/Mappers/MessageMapper.swift Uses shared sender-ID helper for consistent sender resolution.
src/IMessage/Sources/IMessage/Hashing/PlatformAPI+Hashing.swift Adds mapAndHashMessagesByRowID and factors out reaction hashing.
src/IMessage/Sources/IMessage/EventWatcher/EventWatcherLifecycle.swift Carries accountID into the watcher and updates startup to pass cursor/current user context.
src/IMessage/Sources/IMessage/EventWatcher/EventWatcher+Updates.swift Replaces thread refresh emission with granular message/reaction state-sync batching and patch deduplication.
src/IMessage/Sources/IMessage/EventWatcher/EventWatcher+Unreads.swift Switches unread-state tracking keys to chat GUID strings.
src/IMessage/Sources/IMessage/EventWatcher/EventWatcher.swift Stores currentUserID/accountID in the watcher and updates chat-state storage type.
src/IMessage/Sources/IMessage/EventWatcher/ChatRef+Description.swift Removes the old ChatRef hashed description helper.
src/IMessage/Sources/IMDatabaseTestBench/TestBench.swift Updates test-bench unread state handling to use new chatStates() return type.
src/IMessage/Sources/IMDatabase/Support/ChatRef.swift Removes ChatRef from IMDatabase support types.
src/IMessage/Sources/IMDatabase/Models/MessageUpdatesCursor.swift Introduces a shared cursor model for incremental message update tracking.
src/IMessage/Sources/IMDatabase/Models/MappedDatabaseRows+DictionaryBridges.swift Adds reply_to_guid bridging for mapped message rows.
src/IMessage/Sources/IMDatabase/Models/MappedDatabaseRows.swift Adds replyToGUID to MappedMessageRow for reaction-action chaining.
src/IMessage/Sources/IMDatabase/Database/IMDatabase+Updates.swift Replaces “updated chats” query with “updated messages” query + cursor advancement.
src/IMessage/Sources/IMDatabase/Database/IMDatabase+Unreads.swift Changes unread-state query/return type to be keyed by chat GUID.
src/IMessage/Sources/IMDatabase/Database/IMDatabase+MappedMessages.swift Adds cursor snapshot query; adds batched mappedMessageRows by GUID/ROWID with chunking.
src/api.ts Revives date fields on Swift-bridge events before handing them to the JS consumer.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +96 to +98
chatStates.removeValue(forKey: chatGUID)
log.info("chat \(chatGUID) was deleted from iMessage")
return Hasher.thread.tokenizeRemembering(pii: chatGUID)
Comment on lines +6 to 20
private let updatedMessagesSinceQuery = """
SELECT
m.ROWID,
m.date_read,
m.date_edited,
c.ROWID,
c.guid
FROM
message m
LEFT JOIN chat_message_join cmj ON cmj.message_id = m.ROWID
LEFT JOIN chat c ON cmj.chat_id = c.ROWID
WHERE
m.ROWID > ? OR m.date_read > ? OR m.date_edited > ?
GROUP BY
c.guid
ORDER BY
date DESC
m.ROWID ASC
"""
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9e9cc06001

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +230 to +234
guard replyToGUID != target.messageGUID,
replyToGUID != target.messageID else {
return nil
}
return replyToGUID
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize reply_to_guid before deciding message deletes

previousReactionActionMessageID compares reply_to_guid directly to normalized target IDs and then returns the raw value as the delete ID. Because associated-message IDs are normalized elsewhere (via parseAssociatedMessageTarget) from prefixed forms like p:<part>/... or bp:..., a prefixed reply_to_guid that still points at the original target can be misclassified as a prior hidden action row and produce a bad deleteMessages ID. In that case reaction-action cleanup is inconsistent (either deleting the wrong logical target or failing to delete the intended prior hidden row).

Useful? React with 👍 / 👎.

Comment thread src/swift-json.ts Outdated
Comment on lines +30 to +31
const reviveSwiftEventEntry = (entry: unknown): void => {
if (isMutableRecord(entry)) reviveSwiftDateFields(entry)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deep-revive date fields in state-sync message entries

The new bridge path revives dates for event envelopes and top-level entry fields only, but message upsert/update entries can contain nested timestamp-bearing objects (for example tweet/quoted tweet payloads). Those nested timestamps now stay as numbers instead of Date, unlike the JSON parse path that used recursive reviving, so consumers can receive mixed runtime types for the same message schema depending on delivery path (initial fetch vs. live state sync).

Useful? React with 👍 / 👎.

log.error("message update row \(change.rowID) couldn't be mapped, dropping")
continue
}
let threadID = msgRow.threadID ?? change.chatGUID
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-chat messages route to only one chat. msgRow.threadID is non-nil for any non-orphaned message, so the ?? change.chatGUID fallback never fires. For a message in chats A and B, db.messages(since:) correctly returns two UpdatedMessageChanges (chatGUID=A and chatGUID=B), but mappedMessageRows(rowIDs:) LEFT JOINs chat_message_join and the dedup at line 55 (uniquingKeysWith: { first, _ in first }) keeps only one chat's threadID. Both loop iterations then write to pendingByThreadID[A][rowID], with the second overwriting the first — chat B is never created in pendingByThreadID/batchesByThreadID and gets no event.

The old code's GROUP BY c.guid made refreshMessagesInThread events fire for every affected chat. This is a regression for the iMessage+SMS dual-service case (very common) and after group-ID changes.

Reactions are worse: batch.reactionUpsertsByMessageID[target.messageID, default: []].append(...) runs twice in the dedup-winner chat (once per change row), so chat A receives a duplicated reaction event while chat B receives nothing.

Probably want let threadID = change.chatGUID here; the message body's own threadID may also need overriding so each per-chat batch carries the right value when the same rowID is appended to multiple batches.

// For fresh reactions, `reply_to_guid` can point at the original target
// message. Only delete when it points at a prior hidden reaction/removal
// action row.
guard replyToGUID != target.messageGUID,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replyToGUID is not normalized before comparison. parseAssociatedMessageTarget strips p:N/ and bp: prefixes from associated_message_guid, but the equality guard here compares the raw reply_to_guid against the already-stripped target.messageGUID/target.messageID. Today this is fine — every reply_to_guid value in the existing fixtures is a bare UUID — but if iMessage ever stores a prefixed form in reply_to_guid (no schema constraint prevents it), the guards become dead and a prefixed/malformed ID would flow through to batch.deletes (and into a deleteMessages event that the renderer can't match).

Safer to normalize first:

let normalized = parseAssociatedMessageTarget(raw).messageGUID
guard normalized != target.messageGUID,
      normalized != target.messageID else { return nil }
return normalized

if change.isNew {
batch.upserts.append(contentsOf: mappedMessages)
}
if let kind = MessageUpdateKind(change) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: when change.isNew is true the mappedMessages are already going into batch.upserts with seen/behavior/isDelivered/isErrored set, so the follow-up .read (or .edited) update patch is redundant data on the wire. Cheap to gate on else if so the update path only runs when the message wasn't already upserted in this tick:

if change.isNew {
    batch.upserts.append(contentsOf: mappedMessages)
} else if let kind = MessageUpdateKind(change) {
    batch.updates.append(contentsOf: mappedMessages.compactMap { kind.patch(for: $0) })
}

Not a correctness issue today, just less work for the renderer in the (uncommon but real) backdated/iCloud-synced case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants