diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index f0643d25c..ab3ab5976 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -121,9 +121,17 @@ class Context( * Lazy so commands that don't touch persistent event state pay zero * open cost (no `.lock` file, no seed allocation). Closed by * [close] when this Context shuts down. + * + * Files are written pretty-printed (not the compact NIP-01 canonical + * form) so `cat`, `jq`, `git diff` are useful out of the box — + * humans inspect these files. Verification always re-canonicalises, + * so the stored bytes never feed back into a signature check. */ val store: IEventStore by lazy { - FsEventStore(dataDir.eventsDir.toPath()) + FsEventStore( + root = dataDir.eventsDir.toPath(), + eventToJson = com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper::toJsonPretty, + ) } /** Fully-wired manager. Call [prepare] once before use to load persisted state. */ diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt index 53c981091..b8abc79d8 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt @@ -141,6 +141,29 @@ class JacksonMapper { fun toJson(event: Event): String = EventManualSerializer.toJson(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content, event.sig) + /** + * Pretty-printed event JSON for human inspection. Uses the + * [InliningTagArrayPrettyPrinter] already configured on the + * mapper so each tag array stays on its own line (no nested + * line-per-element noise) and the seven event fields get their + * own indented lines. Re-canonicalisation is the caller's + * problem — this output is not canonical NIP-01. + */ + fun toJsonPretty(event: Event): String = + mapper + .writerWithDefaultPrettyPrinter() + .writeValueAsString( + EventManualSerializer.assemble( + event.id, + event.pubKey, + event.createdAt, + event.kind, + event.tags, + event.content, + event.sig, + ), + ) + fun toJson(event: ArrayNode): String = mapper.writeValueAsString(event) fun toJson(event: ObjectNode?): String = mapper.writeValueAsString(event) 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 index d4e866fa7..c5b2aefd1 100644 --- 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 @@ -67,6 +67,14 @@ open class FsEventStore( * `SQLiteEventStore`'s relay arg semantics. */ private val relay: NormalizedRelayUrl? = null, + /** + * How to render an event to JSON before writing the canonical file. + * Default is the compact NIP-01 form ([Event.toJson]); CLIs that + * surface store files to humans pass a pretty-printer instead. The + * stored bytes are not re-used for signature checks anyway — + * verification re-canonicalises — so format is purely a UX choice. + */ + private val eventToJson: (Event) -> String = Event::toJson, ) : IEventStore { private val layout = FsLayout(root) private val hasher: TagNameValueHasher @@ -126,7 +134,7 @@ open class FsEventStore( Files.createDirectories(canonical.parent) val tmp = Files.createTempFile(layout.staging, event.id, FsLayout.JSON_EXT) try { - Files.writeString(tmp, event.toJson()) + Files.writeString(tmp, eventToJson(event)) try { Files.move(tmp, canonical, StandardCopyOption.ATOMIC_MOVE) } catch (_: FileAlreadyExistsException) { diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventToJsonTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventToJsonTest.kt new file mode 100644 index 000000000..8132aedf3 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventToJsonTest.kt @@ -0,0 +1,116 @@ +/* + * 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.jackson.JacksonMapper +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.io.path.readText +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FsEventToJsonTest { + private val signer = NostrSignerSync() + private lateinit var root: Path + + @BeforeTest + fun setup() { + Secp256k1Instance + root = Files.createTempDirectory("fs-fmt-") + } + + @AfterTest + fun tearDown() { + if (root.exists()) { + Files.walk(root).use { it.sorted(Comparator.reverseOrder()).forEach { p -> Files.deleteIfExists(p) } } + } + } + + @Test + fun `default formatter writes compact JSON one line`() { + val store = FsEventStore(root) + try { + val n = + signer.sign( + TextNoteEvent.build("hello", createdAt = 100), + ) + store.insert(n) + val canonical = + root + .resolve("events") + .resolve(n.id.substring(0, 2)) + .resolve(n.id.substring(2, 4)) + .resolve("${n.id}.json") + val raw = canonical.readText() + assertEquals(raw.trim(), raw, "compact form has no trailing whitespace") + assertTrue(!raw.contains('\n'), "compact form is single-line") + } finally { + store.close() + } + } + + @Test + fun `pretty formatter writes multi-line indented JSON and round-trips`() { + val store = + FsEventStore( + root, + eventToJson = JacksonMapper::toJsonPretty, + ) + try { + val n = + signer.sign( + TextNoteEvent.build("hello", createdAt = 100), + ) + store.insert(n) + val canonical = + root + .resolve("events") + .resolve(n.id.substring(0, 2)) + .resolve(n.id.substring(2, 4)) + .resolve("${n.id}.json") + val raw = canonical.readText() + assertTrue(raw.contains('\n'), "pretty form is multi-line") + assertTrue(raw.contains("\"id\""), "field labels survive pretty print") + + // Round-trip: parsing pretty output back must produce the same event. + val reparsed = Event.fromJson(raw) + assertEquals(n.id, reparsed.id) + assertEquals(n.content, reparsed.content) + assertEquals(n.sig, reparsed.sig) + + // And the store can read it back through its own API. + val got = store.query(Filter(ids = listOf(n.id))) + assertEquals(1, got.size) + assertEquals(n.id, got[0].id) + } finally { + store.close() + } + } +}