Sync: a flag set on the server left the thread counters stale
Reported from the running app: a message showed as unread in the list — bold, blue dot — while the reading pane showed it as read, and only a manual "mark as read" reconciled them. The two views read different things. The pane reads the message's own flags; the list reads `threads.unread_count`, denormalized from them. `apply_delta` updated message flags with a bare UPDATE and never recomputed the thread, so a flag set anywhere but in this app — read on a phone, filed by a server-side rule — moved one and not the other. Auto-mark-on-read could not fix it either: it sees a message that is already \Seen and correctly does nothing, leaving the counter wrong forever. Expunges had the same hole, which also left `message_count` overstated. `apply_delta` now collects the affected threads and re-denormalizes them after the deletes, so a thread emptied by an expunge is removed rather than recounted. Migration 0018 repairs rows that already drifted — recomputed from the messages, so it is a no-op on a healthy database. One thread was stale here; installs that have been syncing longer will have more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
"""repair thread counters left stale by server-side flag changes and expunges
|
||||
|
||||
`threads.unread_count`, `pinned` and `message_count` are denormalized from the
|
||||
thread's messages. Two sync paths updated messages without recomputing them: a
|
||||
flag change reported by the server (mail read on a phone, or filed by a
|
||||
server-side rule) and a QRESYNC expunge. The row then disagreed with its own
|
||||
messages — the message list showing a message as unread that the reading pane
|
||||
showed as read, and only a manual "mark as read" reconciling it.
|
||||
|
||||
The code paths are fixed; this repairs rows that already drifted. Recomputes from
|
||||
the messages themselves, so it is safe to run on a healthy database: threads that
|
||||
already agree are left untouched.
|
||||
|
||||
Revision ID: 0018
|
||||
Revises: 0017
|
||||
Create Date: 2026-09-18
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0018"
|
||||
down_revision: str | None = "0017"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
r"""
|
||||
UPDATE threads t
|
||||
SET unread_count = s.unread,
|
||||
pinned = s.pinned,
|
||||
message_count = s.total
|
||||
FROM (
|
||||
SELECT thread_id,
|
||||
count(*) AS total,
|
||||
count(*) FILTER (WHERE NOT ('\Seen' = ANY(flags))) AS unread,
|
||||
bool_or('\Flagged' = ANY(flags)) AS pinned
|
||||
FROM messages
|
||||
WHERE thread_id IS NOT NULL
|
||||
GROUP BY thread_id
|
||||
) AS s
|
||||
WHERE t.id = s.thread_id
|
||||
AND (t.unread_count, t.pinned, t.message_count) IS DISTINCT FROM
|
||||
(s.unread, s.pinned, s.total)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Nothing to undo: this only makes rows agree with the messages they describe."""
|
||||
Reference in New Issue
Block a user