Skip to content

Add read-status and is-from-me predicates to MessagePredicate #17

Description

@sebpretzer

Context

Message has exposed readAt (and computed isRead) since 0.3.0, and isFromMe since the beginning, but MessagePredicate has no case for either:

public indirect enum MessagePredicate: Sendable, Hashable {
    case all, none
    case chatID(Chat.ID)
    case participantHandles(Set<Account.Handle>)
    case dateRange(Range<Date>)
    case and([MessagePredicate]), or([MessagePredicate]), not(MessagePredicate)
}

So a consumer that wants unread messages has to fetch a batch and post-filter it in Swift. That means the LIMIT applies to candidates rather than to matches: you fetch N messages, filter them, and get back however many happen to qualify. A large read backlog pushes unread messages outside the window entirely, and the caller has no way to widen it except by pulling more rows into memory.

This came up implementing unread filtering for iMCP (mattt/iMCP#154, mattt/iMCP#195). That PR post-filters for exactly this reason, and documents the resulting limitation. If these predicates existed, the filter would move into the query and the limitation would go away — with no change to iMCP's tool schema, only to what runs behind it.

Proposal

Two new cases:

/// Match messages according to whether they have been read.
case isRead(Bool)
/// Match messages according to whether they were sent by the current user.
case isFromMe(Bool)

and the corresponding branches in compileMessagePredicate(_:):

case .isRead(let isRead):
    return CompiledPredicate(
        whereClause: isRead
            ? "m.date_read IS NOT NULL AND m.date_read != 0"
            : "(m.date_read IS NULL OR m.date_read = 0)",
        parameters: [],
        requiresChatJoin: false
    )
case .isFromMe(let isFromMe):
    return CompiledPredicate(
        whereClause: isFromMe ? "m.is_from_me = 1" : "m.is_from_me = 0",
        parameters: [],
        requiresChatJoin: false
    )

Unread incoming messages then compose the same way everything else does:

try db.fetch(
    MessageFetchRequest(
        predicate: .and([.isRead(false), .isFromMe(false)]),
        limit: 30
    )
)

Both columns are already in the SELECT in Database.fetch(_:), so nothing else changes.

On the SQL

m.date_read = 0 alone would be wrong, and it's worth being explicit about why. date_read is nullable, and Database.fetch(_:) decodes it as:

let rawReadAt = sqlite3_column_int64(statement, 7)
let readAt = rawReadAt == 0 ? nil : Date(nanosecondsSinceReferenceDate: rawReadAt)

sqlite3_column_int64 maps SQL NULL to 0, so both NULL and 0 produce readAt == nil. SQL = 0 does not match NULL, so the predicate needs (m.date_read IS NULL OR m.date_read = 0) to agree with Message.isRead. #10 proposed is_read = 0, which is a different column and one you stopped reading in 0.3.0 — worth not carrying that forward.

Relationship to #10

I don't read this as relitigating that PR. Your note there was that fetchMessages should stay free of an unread filter flag, with readAt canonical and isRead computed — and that's exactly the shape this keeps. A predicate case isn't a single-purpose parameter on a fetch function; it's a primitive that composes with chatID, participantHandles, and dateRange like any other. The predicate system in #14 landed the day after that comment, which is presumably why read status never got one.

Happy to send a PR with tests if you'd take it.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions