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.
Context
Messagehas exposedreadAt(and computedisRead) since 0.3.0, andisFromMesince the beginning, butMessagePredicatehas no case for either:So a consumer that wants unread messages has to fetch a batch and post-filter it in Swift. That means the
LIMITapplies 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:
and the corresponding branches in
compileMessagePredicate(_:):Unread incoming messages then compose the same way everything else does:
Both columns are already in the
SELECTinDatabase.fetch(_:), so nothing else changes.On the SQL
m.date_read = 0alone would be wrong, and it's worth being explicit about why.date_readis nullable, andDatabase.fetch(_:)decodes it as:sqlite3_column_int64maps SQLNULLto0, so bothNULLand0producereadAt == nil. SQL= 0does not matchNULL, so the predicate needs(m.date_read IS NULL OR m.date_read = 0)to agree withMessage.isRead. #10 proposedis_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
fetchMessagesshould stay free of an unread filter flag, withreadAtcanonical andisReadcomputed — 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 withchatID,participantHandles, anddateRangelike 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.