mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
@@ -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
|
||||
|
||||
+2
@@ -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?,
|
||||
|
||||
+38
@@ -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(
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+107
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user