diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt index 28122a586d..feef5af416 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt @@ -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): 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 { + val withOutbox = HashSet() + query(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND))) { withOutbox.add(it.pubKey) } + + val missing = LinkedHashSet() + query(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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt index c0eb526e11..13545cef2e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt @@ -77,6 +77,8 @@ class EventStore( override suspend fun count(filters: List) = store.count(filters) + override suspend fun authorsMissingOutbox() = store.authorsMissingOutbox() + override suspend fun snapshotIdsForNegentropy( filters: List, maxEntries: Int?, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt index b0af28983d..b4309678c5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt @@ -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 { + 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() + 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( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 8ca31300f7..eea4950f40 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -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): Int = pool.useReader { queryBuilder.count(filters, it) } + suspend fun authorsMissingOutbox(): List = pool.useReader { queryBuilder.authorsMissingKind(AdvertisedRelayListEvent.KIND, it) } + suspend fun snapshotIdsForNegentropy( filters: List, maxEntries: Int? = null, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AuthorsMissingOutboxTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AuthorsMissingOutboxTest.kt new file mode 100644 index 0000000000..01dfa3d0c5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AuthorsMissingOutboxTest.kt @@ -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()) + } +}