diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt new file mode 100644 index 000000000..5eb069a30 --- /dev/null +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt @@ -0,0 +1,200 @@ +/* + * 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.core.isEphemeral +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import java.nio.file.FileAlreadyExistsException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.readText + +/** + * Filesystem-backed `IEventStore`. Each event is stored as a JSON file under + * `events///.json` where `` is the first 4 hex characters + * of the event id. Writes are atomic (tmp + rename). + * + * This is the step-1 skeleton: only `insert`, `delete(id)`, and a minimal + * `query` by event id. Indexes, tombstones, slots, FTS, vanish, expiration + * sweep, and transactions arrive in later steps — see + * `cli/plans/2026-04-24-file-event-store-*.md`. + */ +class FsEventStore( + val root: Path, +) : IEventStore { + private val eventsDir: Path = root.resolve(EVENTS_DIR) + private val stagingDir: Path = root.resolve(STAGING_DIR) + + init { + Files.createDirectories(eventsDir) + Files.createDirectories(stagingDir) + cleanStaging() + } + + // ------------------------------------------------------------------ + // Insert + // ------------------------------------------------------------------ + + override fun insert(event: Event) { + if (event.kind.isEphemeral()) return + + val canonical = canonicalPath(event.id) + if (canonical.exists()) return + + Files.createDirectories(canonical.parent) + val tmp = Files.createTempFile(stagingDir, event.id, JSON_EXT) + try { + Files.writeString(tmp, event.toJson()) + try { + Files.move(tmp, canonical, StandardCopyOption.ATOMIC_MOVE) + } catch (_: FileAlreadyExistsException) { + // Racing writer installed the same id first. Canonical is + // immutable — this is a no-op, matching SQLite's unique + // constraint on event.id. + Files.deleteIfExists(tmp) + } + } catch (t: Throwable) { + Files.deleteIfExists(tmp) + throw t + } + } + + override fun transaction(body: IEventStore.ITransaction.() -> Unit) { + val txn = + object : IEventStore.ITransaction { + override fun insert(event: Event) = this@FsEventStore.insert(event) + } + txn.body() + } + + // ------------------------------------------------------------------ + // Query (by id only — full planner arrives in step 2) + // ------------------------------------------------------------------ + + @Suppress("UNCHECKED_CAST") + override fun query(filter: Filter): List { + val out = mutableListOf() + query(filter) { out.add(it) } + return out + } + + override fun query(filters: List): List { + val seen = HashSet() + val out = mutableListOf() + filters.forEach { f -> + query(f) { if (seen.add(it.id)) out.add(it) } + } + return out + } + + override fun query( + filter: Filter, + onEach: (T) -> Unit, + ) { + val ids = filter.ids ?: return // step-1: only id lookups are implemented + @Suppress("UNCHECKED_CAST") + ids.forEach { id -> + readEvent(id)?.let { onEach(it as T) } + } + } + + override fun query( + filters: List, + onEach: (T) -> Unit, + ) { + val seen = HashSet() + filters.forEach { f -> + query(f) { if (seen.add(it.id)) onEach(it) } + } + } + + override fun count(filter: Filter): Int { + var n = 0 + query(filter) { n++ } + return n + } + + override fun count(filters: List): Int { + var n = 0 + query(filters) { n++ } + return n + } + + // ------------------------------------------------------------------ + // Delete + // ------------------------------------------------------------------ + + override fun delete(filter: Filter) { + val ids = filter.ids ?: return + ids.forEach { delete(it) } + } + + override fun delete(filters: List) = filters.forEach(::delete) + + /** Delete an event by id. Returns 1 if a file was removed, 0 otherwise. */ + fun delete(id: HexKey): Int = if (canonicalPath(id).deleteIfExists()) 1 else 0 + + override fun deleteExpiredEvents() { + // Step-5 feature. No-op in skeleton — queries do not yet surface + // expired events either, so nothing observable changes. + } + + override fun close() { + // No long-lived resources yet. The lock channel arrives in step 8. + } + + // ------------------------------------------------------------------ + // Internals + // ------------------------------------------------------------------ + + private fun canonicalPath(id: HexKey): Path { + require(id.length >= 4) { "event id must be at least 4 hex chars, got '$id'" } + return eventsDir.resolve(id.substring(0, 2)).resolve(id.substring(2, 4)).resolve("$id$JSON_EXT") + } + + private fun readEvent(id: HexKey): Event? { + val p = canonicalPath(id) + if (!p.exists()) return null + return try { + Event.fromJson(p.readText()) + } catch (_: java.nio.file.NoSuchFileException) { + null // file was removed between exists() and read — treat as absent + } + } + + private fun cleanStaging() { + Files.list(stagingDir).use { stream -> + stream.forEach { Files.deleteIfExists(it) } + } + } + + companion object { + const val EVENTS_DIR = "events" + const val STAGING_DIR = ".staging" + const val JSON_EXT = ".json" + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStoreTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStoreTest.kt new file mode 100644 index 000000000..1df0509ba --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStoreTest.kt @@ -0,0 +1,186 @@ +/* + * 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.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +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 +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FsEventStoreTest { + private val signer = NostrSignerSync() + private lateinit var root: Path + private lateinit var store: FsEventStore + + @BeforeTest + fun setup() { + Secp256k1Instance // force crypto lib load + root = Files.createTempDirectory("fs-store-") + store = FsEventStore(root) + } + + @AfterTest + fun tearDown() { + store.close() + deleteRecursively(root) + } + + private fun deleteRecursively(path: Path) { + if (!path.exists()) return + Files.walk(path).use { stream -> + stream + .sorted(Comparator.reverseOrder()) + .forEach { Files.deleteIfExists(it) } + } + } + + @Test + fun `insert and query by id round-trips`() { + val note = signer.sign(TextNoteEvent.build("hello")) + + store.insert(note) + + val got = store.query(Filter(ids = listOf(note.id))) + assertEquals(1, got.size) + assertEquals(note.id, got[0].id) + assertEquals(note.content, got[0].content) + assertEquals(note.sig, got[0].sig) + } + + @Test + fun `canonical path uses 2-char sharding`() { + val note = signer.sign(TextNoteEvent.build("shard me")) + store.insert(note) + + val shard = root.resolve("events").resolve(note.id.substring(0, 2)).resolve(note.id.substring(2, 4)) + val file = shard.resolve("${note.id}.json") + assertTrue(file.exists(), "expected canonical at $file") + } + + @Test + fun `query returns empty when nothing inserted`() { + val note = signer.sign(TextNoteEvent.build("missing")) + assertEquals(emptyList(), store.query(Filter(ids = listOf(note.id)))) + } + + @Test + fun `delete by id removes the file`() { + val note = signer.sign(TextNoteEvent.build("to-delete")) + store.insert(note) + assertEquals(1, store.count(Filter(ids = listOf(note.id)))) + + val removed = store.delete(note.id) + assertEquals(1, removed) + assertEquals(0, store.count(Filter(ids = listOf(note.id)))) + } + + @Test + fun `delete returns 0 when event absent`() { + val note = signer.sign(TextNoteEvent.build("never-inserted")) + assertEquals(0, store.delete(note.id)) + } + + @Test + fun `delete by filter with ids removes matching events`() { + val a = signer.sign(TextNoteEvent.build("a")) + val b = signer.sign(TextNoteEvent.build("b")) + store.insert(a) + store.insert(b) + + store.delete(Filter(ids = listOf(a.id))) + + assertNull(store.query(Filter(ids = listOf(a.id))).firstOrNull()) + assertEquals(b.id, store.query(Filter(ids = listOf(b.id))).single().id) + } + + @Test + fun `insert of duplicate id is a no-op`() { + val note = signer.sign(TextNoteEvent.build("dup")) + store.insert(note) + store.insert(note) // must not throw; content is immutable anyway + assertEquals(1, store.count(Filter(ids = listOf(note.id)))) + } + + @Test + fun `ephemeral events are not persisted`() { + // Kind 20_000 is the lowest ephemeral kind; use a bare Event + // constructed inline because TextNoteEvent pins kind=1. + val ephemeral = + signer.sign( + createdAt = 1, + kind = 20_000, + tags = emptyArray(), + content = "ghost", + ) + store.insert(ephemeral) + assertEquals(0, store.count(Filter(ids = listOf(ephemeral.id)))) + } + + @Test + fun `ids that share the same 4-char shard both persist`() { + // Find two real events whose ids share the same first 4 hex chars. + // With a random KeyPair per sign, this takes a handful of tries. + var a = signer.sign(TextNoteEvent.build("a0", createdAt = 1)) + var b: TextNoteEvent + var salt = 2L + do { + b = signer.sign(TextNoteEvent.build("b$salt", createdAt = salt)) + salt++ + } while (b.id.substring(0, 4) != a.id.substring(0, 4) && salt < 200_000) + if (b.id.substring(0, 4) != a.id.substring(0, 4)) { + // Didn't find a collision cheaply. Fall back to inserting two + // unrelated events and checking they both live under their own + // shards — still verifies basic sharding without flakiness. + b = signer.sign(TextNoteEvent.build("unrelated")) + } + + store.insert(a) + store.insert(b) + + assertTrue(store.count(Filter(ids = listOf(a.id))) == 1) + assertTrue(store.count(Filter(ids = listOf(b.id))) == 1) + } + + @Test + fun `staging dir is cleared on init`() { + val staging = root.resolve(".staging") + val leftover = Files.createTempFile(staging, "crash-", ".json") + assertTrue(leftover.exists()) + + // Reopening the store should sweep the staging dir. + val reopened = FsEventStore(root) + try { + assertFalse(leftover.exists(), "staging leftover should be cleared on open") + } finally { + reopened.close() + } + } +}