From 44470b48213b094f80b001ceaddc107bc6f5de55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 00:48:01 +0000 Subject: [PATCH] test(quartz): SQLite parity matrix for FsEventStore (step 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives both EventStore (SQLite reference) and FsEventStore with identical event streams and asserts query() result sets match. SQLite throws on blocked inserts (NIP-09 tombstones, NIP-62 vanish, NIP-40 expiration) while the FS store silently skips — both are observable "event not persisted", so insertBoth() catches SQLite throws and parity is judged on the post-insert state. Coverage: id lookup, kind+author, since/until/limit, single-letter tag query (OR within key), replaceable winner, replaceable older rejected, addressable d-tag dedup, deletion by id (incl. block- reinsert), deletion by address (incl. cutoff semantics), expiration sweep, NIP-50 single-token search (sticking to ASCII where SQLite's unicode61 tokenizer agrees with our port), count, multi-filter union, delete by filter, mixed kitchen-sink scenario. 113 fs tests now green (97 unit + 16 parity). --- .../quartz/nip01Core/store/fs/FsParityTest.kt | 445 ++++++++++++++++++ 1 file changed, 445 insertions(+) create mode 100644 quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsParityTest.kt diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsParityTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsParityTest.kt new file mode 100644 index 000000000..4fbf749b7 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsParityTest.kt @@ -0,0 +1,445 @@ +/* + * 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.fs + +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.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Parity matrix: drive both [EventStore] (SQLite) and [FsEventStore] + * with identical event streams and assert their `query()` results + * agree. This is the strongest end-to-end check that the file-backed + * store implements the same semantics as the SQLite reference. + * + * SQLite throws on blocked inserts (NIP-09 tombstones, NIP-62 vanish); + * the FS store silently skips. Both observable outcomes are "event not + * persisted", so the parity helper catches insert exceptions on the + * SQLite side and proceeds — what matters is the post-insert state. + */ +class FsParityTest { + private val signer = NostrSignerSync() + private val otherSigner = NostrSignerSync() + + private lateinit var fsRoot: Path + private lateinit var fs: FsEventStore + private lateinit var sqlite: EventStore + + @BeforeTest + fun setup() { + Secp256k1Instance + fsRoot = Files.createTempDirectory("fs-parity-") + fs = FsEventStore(fsRoot) + // Pass dbName=null so SQLite uses an in-memory database. + sqlite = EventStore(dbName = null) + } + + @AfterTest + fun tearDown() { + try { + fs.close() + } catch (_: Throwable) { + } + try { + sqlite.close() + } catch (_: Throwable) { + } + if (fsRoot.exists()) { + Files.walk(fsRoot).use { it.sorted(Comparator.reverseOrder()).forEach { p -> Files.deleteIfExists(p) } } + } + } + + /** Insert into both stores. Swallow SQLite rejections (we only care about the resulting state). */ + private fun insertBoth(event: Event) { + try { + sqlite.insert(event) + } catch (_: Throwable) { + } + try { + fs.insert(event) + } catch (_: Throwable) { + } + } + + /** Assert both stores return the same ids (as a set) for the given filter. */ + private fun assertParity( + filter: Filter, + message: String = "", + ) { + val sqliteIds = sqlite.query(filter).map { it.id }.toSet() + val fsIds = fs.query(filter).map { it.id }.toSet() + assertEquals(sqliteIds, fsIds, "parity mismatch: $message") + } + + /** Same, but expect a stable DESC-by-createdAt ordering. */ + private fun assertParityOrdered( + filter: Filter, + message: String = "", + ) { + val sqliteIds = sqlite.query(filter).map { it.id } + val fsIds = fs.query(filter).map { it.id } + assertEquals(sqliteIds, fsIds, "ordered parity mismatch: $message") + } + + private fun note( + body: String, + ts: Long, + s: NostrSignerSync = signer, + ): TextNoteEvent = s.sign(TextNoteEvent.build(body, createdAt = ts)) + + private fun article( + slug: String, + body: String, + ts: Long, + ): LongTextNoteEvent = + signer.sign( + createdAt = ts, + kind = LongTextNoteEvent.KIND, + tags = arrayOf(arrayOf("d", slug)), + content = body, + ) + + // ------------------------------------------------------------------ + // Basic insert / query + // ------------------------------------------------------------------ + + @Test + fun `id lookup matches`() { + val n = note("hello", 10) + insertBoth(n) + assertParity(Filter(ids = listOf(n.id))) + } + + @Test + fun `kind + author query matches`() { + val a = note("a", 1) + val b = note("b", 2) + val c = note("c", 3, s = otherSigner) + listOf(a, b, c).forEach(::insertBoth) + + assertParityOrdered(Filter(kinds = listOf(1), authors = listOf(signer.pubKey))) + assertParityOrdered(Filter(authors = listOf(signer.pubKey, otherSigner.pubKey))) + } + + @Test + fun `since until limit match`() { + repeat(10) { i -> insertBoth(note("n$i", i.toLong() + 1)) } + assertParityOrdered(Filter(authors = listOf(signer.pubKey), since = 4, until = 8)) + assertParityOrdered(Filter(authors = listOf(signer.pubKey), limit = 3)) + } + + // ------------------------------------------------------------------ + // Tag indexing + // ------------------------------------------------------------------ + + @Test + fun `single-letter tag queries match`() { + val tagged = + signer.sign( + createdAt = 5, + kind = 1, + tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "bitcoin")), + content = "x", + ) + val plain = note("plain", 6) + insertBoth(tagged) + insertBoth(plain) + + assertParity(Filter(tags = mapOf("t" to listOf("nostr")))) + assertParity(Filter(tags = mapOf("t" to listOf("nostr", "bitcoin")))) + } + + // ------------------------------------------------------------------ + // Replaceable / Addressable + // ------------------------------------------------------------------ + + @Test + fun `replaceable newer wins parity`() { + val v1 = + signer.sign( + createdAt = 100, + kind = 0, + tags = emptyArray(), + content = "{\"name\":\"v1\"}", + ) + val v2 = + signer.sign( + createdAt = 200, + kind = 0, + tags = emptyArray(), + content = "{\"name\":\"v2\"}", + ) + insertBoth(v1) + insertBoth(v2) + + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0))) + assertParity(Filter(ids = listOf(v1.id))) + } + + @Test + fun `replaceable older rejected parity`() { + val newer = + signer.sign(createdAt = 200, kind = 0, tags = emptyArray(), content = "{\"name\":\"new\"}") + val older = + signer.sign(createdAt = 100, kind = 0, tags = emptyArray(), content = "{\"name\":\"old\"}") + insertBoth(newer) + insertBoth(older) + + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0))) + } + + @Test + fun `addressable d-tag dedup parity`() { + val v1 = article("intro", "v1", 10) + val v2 = article("intro", "v2", 20) + val v3 = article("about", "bio", 15) + insertBoth(v1) + insertBoth(v2) + insertBoth(v3) + + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + } + + // ------------------------------------------------------------------ + // Deletion (NIP-09) + // ------------------------------------------------------------------ + + @Test + fun `deletion by id parity`() { + val a = note("a", 10) + val b = note("b", 20) + insertBoth(a) + insertBoth(b) + + val del = signer.sign(DeletionEvent.build(listOf(a), createdAt = 30)) + insertBoth(del) + + assertParity(Filter(ids = listOf(a.id))) + assertParity(Filter(ids = listOf(b.id))) + assertParity(Filter(kinds = listOf(DeletionEvent.KIND))) + + // Re-insert blocked. + insertBoth(a) + assertParity(Filter(ids = listOf(a.id))) + } + + @Test + fun `deletion by address parity`() { + val v = article("intro", "v1", 10) + insertBoth(v) + + val del = signer.sign(DeletionEvent.build(listOf(v), createdAt = 20)) + insertBoth(del) + + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + + // Older event at this address must be blocked, newer must pass. + insertBoth(article("intro", "older", 5)) + insertBoth(article("intro", "newer", 100)) + + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) + } + + // ------------------------------------------------------------------ + // Expiration (NIP-40) + // ------------------------------------------------------------------ + + @Test + fun `expiration sweep parity`() { + // Build events with future-then-past expirations relative to now. + val now = + com.vitorpamplona.quartz.utils.TimeUtils + .now() + val expired = + signer.sign( + createdAt = now - 100, + kind = 1, + tags = arrayOf(arrayOf("expiration", (now - 50).toString())), + content = "old", + ) + val alive = + signer.sign( + createdAt = now - 100, + kind = 1, + tags = arrayOf(arrayOf("expiration", (now + 1_000_000).toString())), + content = "still here", + ) + insertBoth(expired) // both stores reject (already expired) + insertBoth(alive) + + assertParity(Filter(ids = listOf(expired.id))) + assertParity(Filter(ids = listOf(alive.id))) + + // Sweep both; alive survives. + sqlite.deleteExpiredEvents() + fs.deleteExpiredEvents() + assertParity(Filter(ids = listOf(alive.id))) + } + + // ------------------------------------------------------------------ + // Search (NIP-50) + // ------------------------------------------------------------------ + + @Test + fun `search parity`() { + val a = note("hello bitcoin", 1) + val b = note("nostr only", 2) + val c = note("bitcoin and nostr", 3) + insertBoth(a) + insertBoth(b) + insertBoth(c) + + // Tokenizers differ slightly between SQLite FTS5 unicode61 and + // our Kotlin port, so we stick to plain ASCII single-token queries + // where both should agree. + assertParity(Filter(search = "bitcoin")) + assertParity(Filter(search = "nostr")) + } + + // ------------------------------------------------------------------ + // Count + // ------------------------------------------------------------------ + + @Test + fun `count parity across mixed stream`() { + listOf( + note("a", 1), + note("b", 2), + note("c", 3), + note("from-other", 4, s = otherSigner), + ).forEach(::insertBoth) + + val filter = Filter(authors = listOf(signer.pubKey)) + assertEquals(sqlite.count(filter), fs.count(filter)) + } + + // ------------------------------------------------------------------ + // Mixed kitchen-sink scenario + // ------------------------------------------------------------------ + + @Test + fun `kitchen sink scenario`() { + // Notes + val n1 = note("first", 1) + val n2 = note("second", 2) + // Replaceable + val meta1 = + signer.sign(createdAt = 10, kind = 0, tags = emptyArray(), content = "{\"name\":\"v1\"}") + val meta2 = + signer.sign(createdAt = 20, kind = 0, tags = emptyArray(), content = "{\"name\":\"v2\"}") + // Addressable + val artA = article("a", "A v1", 30) + val artB = article("b", "B v1", 30) + val artBv2 = article("b", "B v2", 50) + // Deletion of n1 + val del = signer.sign(DeletionEvent.build(listOf(n1), createdAt = 40)) + + listOf(n1, n2, meta1, meta2, artA, artB, artBv2, del).forEach(::insertBoth) + + // Snapshots that should match. + assertParity(Filter(ids = listOf(n1.id)), "n1 deleted") + assertParity(Filter(ids = listOf(n2.id)), "n2 alive") + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0)), "metadata winner") + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND)), "articles set") + assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(DeletionEvent.KIND)), "deletion present") + } + + // ------------------------------------------------------------------ + // Multi-filter union + // ------------------------------------------------------------------ + + @Test + fun `multi-filter union parity`() { + val a = note("a", 1) + val b = note("b", 2, s = otherSigner) + insertBoth(a) + insertBoth(b) + + val filters = + listOf( + Filter(authors = listOf(signer.pubKey)), + Filter(authors = listOf(otherSigner.pubKey)), + ) + + assertEquals( + sqlite.query(filters).map { it.id }.toSet(), + fs.query(filters).map { it.id }.toSet(), + ) + } + + // ------------------------------------------------------------------ + // Direct delete by filter + // ------------------------------------------------------------------ + + @Test + fun `delete by filter parity`() { + val toKill = note("dead", 5) + val survivor = note("alive", 6) + insertBoth(toKill) + insertBoth(survivor) + + sqlite.delete(Filter(ids = listOf(toKill.id))) + fs.delete(Filter(ids = listOf(toKill.id))) + + assertParity(Filter(authors = listOf(signer.pubKey))) + } + + // ------------------------------------------------------------------ + // Helper: ensure SQLite store really does what we think + // ------------------------------------------------------------------ + + @Test + fun `helper sanity - empty stores agree`() { + assertParity(Filter(authors = listOf(signer.pubKey))) + assertParity(Filter(kinds = listOf(1))) + } + + @Suppress("unused") + private fun debugDump(label: String): String { + val sqIds = + sqlite + .query(Filter(authors = listOf(signer.pubKey, otherSigner.pubKey))) + .map { "${it.kind}:${it.id.take(8)}@${it.createdAt}" } + .sorted() + val fsIds = + fs + .query(Filter(authors = listOf(signer.pubKey, otherSigner.pubKey))) + .map { "${it.kind}:${it.id.take(8)}@${it.createdAt}" } + .sorted() + return "$label\n sqlite: $sqIds\n fs: $fsIds" + } + + @Suppress("unused") + private fun ids(events: List): Set = events.map { it.id }.toSet() +}