feat(quartz): add IEventStore.authorsMissingOutbox() anti-join query

Adds a whole-store query returning every distinct author with at least
one stored event that has NO NIP-65 relay list (kind 10002 / outbox).

This is a set-difference the positive-only nostr Filter grammar can't
express (there is no "NOT kind 10002"), so it lives as a dedicated
IEventStore method rather than a query(Filter). The interface carries a
correct default (collect authors-with-outbox, then stream events keeping
the rest — O(events)); SQLiteEventStore overrides it with a single
SELECT DISTINCT ... NOT EXISTS that seeks the outbox check on the
(kind, pubkey, created_at) index.

"Missing" is relative to what the store holds: an author whose only
10002 was deleted (NIP-09) or expired (NIP-40) is reported as missing
again, since no row remains.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuLzfXyVZ16ozG8oJ7hBBc
This commit is contained in:
Claude
2026-07-09 00:18:24 +00:00
parent b5313e28ca
commit 574320cf22
5 changed files with 181 additions and 0 deletions
@@ -22,8 +22,10 @@ package com.vitorpamplona.quartz.nip01Core.store
import com.vitorpamplona.negentropy.storage.IStorage
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
interface IEventStore : AutoCloseable {
companion object {
@@ -121,6 +123,35 @@ interface IEventStore : AutoCloseable {
suspend fun count(filters: List<Filter>): Int
/**
* Every distinct author with at least one stored event that has NO
* NIP-65 relay list (kind 10002 / "outbox") in this store.
*
* This is a whole-store anti-join — the set of all authors minus the
* authors who already have an outbox — which the positive-only nostr
* [Filter] grammar cannot express (there is no "NOT kind 10002"), so
* it is its own method rather than a [query]. "Missing" is relative to
* what THIS store holds (see [relay]); an author whose only 10002 was
* deleted (NIP-09) or expired (NIP-40) is reported as missing, because
* no row remains for it. Order is unspecified.
*
* The default implementation walks the store: it collects the authors
* that DO have an outbox, then streams every event and keeps the
* authors not in that set. Correct for any store but O(events). SQLite
* overrides it with a single `SELECT DISTINCT … NOT EXISTS` scan that
* seeks the outbox lookup on the `(kind, pubkey, …)` index.
*/
suspend fun authorsMissingOutbox(): List<HexKey> {
val withOutbox = HashSet<HexKey>()
query<Event>(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND))) { withOutbox.add(it.pubKey) }
val missing = LinkedHashSet<HexKey>()
query<Event>(Filter()) { event ->
if (event.pubKey !in withOutbox) missing.add(event.pubKey)
}
return missing.toList()
}
/**
* NIP-77 negentropy snapshot. Returns `(created_at, id)` pairs
* for every event matching [filters], with no content/tags/sig
@@ -77,6 +77,8 @@ class EventStore(
override suspend fun count(filters: List<Filter>) = store.count(filters)
override suspend fun authorsMissingOutbox() = store.authorsMissingOutbox()
override suspend fun snapshotIdsForNegentropy(
filters: List<Filter>,
maxEntries: Int?,
@@ -594,6 +594,44 @@ class QueryBuilder(
return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.args)
}
// -----------------------------------------------------------------
// Anti-join projections
//
// Set-difference over authors — "who is missing an event of kind K"
// — which the positive-only nostr Filter grammar can't express, so
// it lives here as a dedicated SELECT rather than going through the
// filter → SQL path.
// -----------------------------------------------------------------
/**
* Distinct authors with at least one stored event that have NO stored
* event of [kind]. The outer scan collects every distinct `pubkey`;
* the correlated `NOT EXISTS` is a point lookup on
* `query_by_kind_pubkey_created` (kind, pubkey, …), so the cost is one
* distinct-pubkey pass plus a seek per author. Order is unspecified.
*/
fun authorsMissingKind(
kind: Int,
db: SQLiteConnection,
): List<HexKey> {
val sql =
"""
SELECT DISTINCT present.pubkey FROM event_headers AS present
WHERE NOT EXISTS (
SELECT 1 FROM event_headers AS wanted
WHERE wanted.kind = ? AND wanted.pubkey = present.pubkey
)
""".trimIndent()
return db.prepare(sql).use { stmt ->
stmt.bindLong(1, kind.toLong())
val out = ArrayList<HexKey>()
while (stmt.step()) {
out.add(stmt.getText(0))
}
out
}
}
private fun SQLiteConnection.countEverything() = runCount("SELECT count(*) as count FROM event_headers")
private fun SQLiteConnection.countIn(
@@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.store.RawEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip77Negentropy.LiveNegentropyIndex
class SQLiteEventStore(
@@ -555,6 +556,8 @@ class SQLiteEventStore(
suspend fun count(filters: List<Filter>): Int = pool.useReader { queryBuilder.count(filters, it) }
suspend fun authorsMissingOutbox(): List<HexKey> = pool.useReader { queryBuilder.authorsMissingKind(AdvertisedRelayListEvent.KIND, it) }
suspend fun snapshotIdsForNegentropy(
filters: List<Filter>,
maxEntries: Int? = null,
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.store.sqlite
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import kotlin.test.Test
import kotlin.test.assertEquals
class AuthorsMissingOutboxTest : BaseDBTest() {
@Test
fun emptyStoreReturnsNoAuthors() =
forEachDB { db ->
assertEquals(emptySet(), db.authorsMissingOutbox().toSet())
}
@Test
fun authorWithEventButNoOutboxIsMissing() =
forEachDB { db ->
val signer = NostrSignerSync()
db.insert(signer.sign(TextNoteEvent.build("hello")))
assertEquals(setOf(signer.pubKey), db.authorsMissingOutbox().toSet())
}
@Test
fun authorWithOutboxIsNotMissing() =
forEachDB { db ->
val hasOutbox = NostrSignerSync()
val noOutbox = NostrSignerSync()
// Both authors have content; only one advertises a 10002.
db.insert(hasOutbox.sign(TextNoteEvent.build("with relays")))
db.insert(AdvertisedRelayListEvent.create(emptyList(), hasOutbox))
db.insert(noOutbox.sign(TextNoteEvent.build("no relays")))
assertEquals(setOf(noOutbox.pubKey), db.authorsMissingOutbox().toSet())
}
@Test
fun authorKnownOnlyByTheirOutboxIsNotMissing() =
forEachDB { db ->
// The only stored event for this author IS the 10002. They must
// not appear (the outer scan sees them, the NOT EXISTS excludes
// them) — the anti-join is symmetric on the same table.
val signer = NostrSignerSync()
db.insert(AdvertisedRelayListEvent.create(emptyList(), signer))
assertEquals(emptySet(), db.authorsMissingOutbox().toSet())
}
@Test
fun outboxDeletedMakesAuthorMissingAgain() =
forEachDB { db ->
val signer = NostrSignerSync()
db.insert(signer.sign(TextNoteEvent.build("content")))
val relayList = AdvertisedRelayListEvent.create(emptyList(), signer)
db.insert(relayList)
assertEquals(emptySet(), db.authorsMissingOutbox().toSet())
// NIP-09: the author deletes their own relay list. No 10002 row
// remains, so the anti-join reports them as missing again.
db.insert(signer.sign(DeletionEvent.build(listOf(relayList))))
assertEquals(setOf(signer.pubKey), db.authorsMissingOutbox().toSet())
}
@Test
fun mixOfAuthorsReportsOnlyThoseWithoutOutbox() =
forEachDB { db ->
val a = NostrSignerSync()
val b = NostrSignerSync()
val c = NostrSignerSync()
db.insert(a.sign(TextNoteEvent.build("a1")))
db.insert(a.sign(TextNoteEvent.build("a2")))
db.insert(AdvertisedRelayListEvent.create(emptyList(), a))
db.insert(b.sign(TextNoteEvent.build("b1")))
db.insert(c.sign(TextNoteEvent.build("c1")))
db.insert(AdvertisedRelayListEvent.create(emptyList(), c))
assertEquals(setOf(b.pubKey), db.authorsMissingOutbox().toSet())
}
}