From 3fc4781d1296f4408ad66c2c0a4a59b53eab02a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 14:30:48 +0000 Subject: [PATCH] refactor(quartz): encapsulate interning as InternedEventStore decorator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the ctor-injected interner inside SQLiteEventStore / QueryBuilder / EventStore / FsEventStore with a standalone InternedEventStore decorator in nip01Core/cache/. Composition is now explicit at the call site: val sqlite = EventStore(...) val cached = InternedEventStore(sqlite) val observable = ObservableEventStore(cached) Each layer has one job: EventStore persists, InternedEventStore canonicalises read results, ObservableEventStore publishes the bus. Stores no longer carry an interner field; passing one through three layers of constructor params is gone. InternedEventStore wraps every IEventStore.query variant (list + streaming, single filter + multi) and pipes results through interner.intern. Writes (insert, transaction, delete*, deleteExpiredEvents) and counts pass through untouched. assertQuery test helpers generalized from EventStore / SQLiteEventStore to IEventStore so the decorator works with the existing fixtures. BaseDBTest reverts to plain EventStore — the basic store tests don't read through the projection / interning layer, so they don't need decoration. Tests that DO want canonical-instance reads can wrap explicitly: `InternedEventStore(eventStore)`. 7/7 interner + 240/240 store + projection tests still pass. https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5 --- .../nip01Core/cache/InternedEventStore.kt | 87 +++++++++++++++++++ .../nip01Core/store/sqlite/EventStore.kt | 4 +- .../nip01Core/store/sqlite/QueryBuilder.kt | 29 +++---- .../store/sqlite/SQLiteEventStore.kt | 3 - .../nip01Core/store/sqlite/AssertUtils.kt | 5 +- .../nip01Core/store/sqlite/BaseDBTest.kt | 7 -- .../quartz/nip01Core/store/fs/FsEventStore.kt | 7 +- 7 files changed, 102 insertions(+), 40 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/InternedEventStore.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/InternedEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/InternedEventStore.kt new file mode 100644 index 000000000..db078e1e5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/InternedEventStore.kt @@ -0,0 +1,87 @@ +/* + * 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.cache + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.IEventStore + +/** + * Decorator that canonicalises every [Event] returned by the inner + * store through an [EventInterner], so all consumers of read paths + * share a single object reference per event id. + * + * Wrap any [IEventStore] (SQLite, FS, in-memory test fake) when you + * want event-identity sharing across the read paths: + * + * ``` + * val sqlite = EventStore(...) + * val cached = InternedEventStore(sqlite) + * val observable = ObservableEventStore(cached) + * ``` + * + * Writes (`insert`, `transaction`, `delete*`) pass through unchanged + * — the decorator never substitutes the caller's event for a cached + * one on the way in (sigs may differ across "same id, different + * decode" cases). Canonicalisation only happens on results coming + * back out of the store. + * + * The default [interner] is [EventInterner.Default]; pass a fresh + * instance for tests or any context that needs isolation. + */ +class InternedEventStore( + private val inner: IEventStore, + private val interner: EventInterner = EventInterner.Default, +) : IEventStore { + override suspend fun insert(event: Event) = inner.insert(event) + + override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) = inner.transaction(body) + + @Suppress("UNCHECKED_CAST") + override suspend fun query(filter: Filter): List = inner.query(filter).map { interner.intern(it) as T } + + @Suppress("UNCHECKED_CAST") + override suspend fun query(filters: List): List = inner.query(filters).map { interner.intern(it) as T } + + @Suppress("UNCHECKED_CAST") + override suspend fun query( + filter: Filter, + onEach: (T) -> Unit, + ) = inner.query(filter) { onEach(interner.intern(it) as T) } + + @Suppress("UNCHECKED_CAST") + override suspend fun query( + filters: List, + onEach: (T) -> Unit, + ) = inner.query(filters) { onEach(interner.intern(it) as T) } + + override suspend fun count(filter: Filter): Int = inner.count(filter) + + override suspend fun count(filters: List): Int = inner.count(filters) + + override suspend fun delete(filter: Filter) = inner.delete(filter) + + override suspend fun delete(filters: List) = inner.delete(filters) + + override suspend fun deleteExpiredEvents() = inner.deleteExpiredEvents() + + override fun close() = inner.close() +} 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 ebe8e8641..0a0f1a75b 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 @@ -21,7 +21,6 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import androidx.sqlite.driver.bundled.BundledSQLiteDriver -import com.vitorpamplona.quartz.nip01Core.cache.EventInterner import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -37,9 +36,8 @@ class EventStore( dbName: String? = "events.db", val relay: NormalizedRelayUrl? = "wss://quartz.local/".normalizeRelayUrl(), val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), - val interner: EventInterner = EventInterner.Default, ) : IEventStore { - val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relay, indexStrategy, interner = interner) + val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relay, indexStrategy) override suspend fun insert(event: Event) = store.insertEvent(event) 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 52151be43..6a3412e52 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 @@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import androidx.sqlite.SQLiteConnection import androidx.sqlite.SQLiteStatement -import com.vitorpamplona.quartz.nip01Core.cache.EventInterner import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind @@ -36,7 +35,6 @@ class QueryBuilder( val fts: FullTextSearchModule, val hasher: (db: SQLiteConnection) -> TagNameValueHasher, val indexStrategy: IndexingStrategy, - val interner: EventInterner = EventInterner.Default, ) { // ------------ // Main methods @@ -234,23 +232,16 @@ class QueryBuilder( } } - @Suppress("UNCHECKED_CAST") - private fun SQLiteStatement.toEvent(): T { - val event = - EventFactory.create( - getText(0), - getText(1), - getLong(2), - getInt(3), - OptimizedJsonMapper.fromJsonToTagArray(getText(4)), - getText(5), - getText(6), - ) - // Events read from durable storage were valid when written, so - // interning their reconstruction is safe. Multiple projections - // re-querying the same id end up sharing one Event instance. - return interner.intern(event) as T - } + private fun SQLiteStatement.toEvent(): T = + EventFactory.create( + getText(0), + getText(1), + getLong(2), + getInt(3), + OptimizedJsonMapper.fromJsonToTagArray(getText(4)), + getText(5), + getText(6), + ) private fun SQLiteStatement.toRawEvent() = RawEvent( 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 107d5cb9d..2da5cf49c 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 @@ -24,7 +24,6 @@ import androidx.sqlite.SQLiteConnection import androidx.sqlite.SQLiteDriver import androidx.sqlite.SQLiteException import androidx.sqlite.driver.bundled.BundledSQLiteDriver -import com.vitorpamplona.quartz.nip01Core.cache.EventInterner import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.Kind @@ -42,7 +41,6 @@ class SQLiteEventStore( val relay: NormalizedRelayUrl? = null, val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), val numReaders: Int = 4, - val interner: EventInterner = EventInterner.Default, ) { companion object { const val DATABASE_VERSION = 2 @@ -70,7 +68,6 @@ class SQLiteEventStore( fullTextSearchModule, seedModule::hasher, indexStrategy, - interner, ) val modules = diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt index d7fa94ce5..57215924c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AssertUtils.kt @@ -22,9 +22,10 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.IEventStore import kotlin.test.assertEquals -suspend fun EventStore.assertQuery( +suspend fun IEventStore.assertQuery( expected: T?, filter: Filter, ) { @@ -40,7 +41,7 @@ suspend fun EventStore.assertQuery( } } -suspend fun EventStore.assertQuery( +suspend fun IEventStore.assertQuery( expected: List, filter: Filter, ) { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt index 74e7291b6..300ceb4cc 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/BaseDBTest.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite -import com.vitorpamplona.quartz.nip01Core.cache.EventInterner import com.vitorpamplona.quartz.utils.Secp256k1Instance import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -64,12 +63,6 @@ open class BaseDBTest { EventStore( dbName = null, indexStrategy = indexStrategy, - // Each store gets its own interner so - // parallel forEachDB runs don't share a - // canonical Event for the same id — - // tests sign with random sigs that - // would otherwise cross-pollinate. - interner = EventInterner(), ) } } 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 a1ff99573..8da5d14b5 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 @@ -20,7 +20,6 @@ */ package com.vitorpamplona.quartz.nip01Core.store.fs -import com.vitorpamplona.quartz.nip01Core.cache.EventInterner import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -76,7 +75,6 @@ open class FsEventStore( * verification re-canonicalises — so format is purely a UX choice. */ private val eventToJson: (Event) -> String = Event::toJson, - private val interner: EventInterner = EventInterner.Default, ) : IEventStore { private val layout = FsLayout(root) private val hasher: TagNameValueHasher @@ -510,10 +508,7 @@ open class FsEventStore( val p = layout.canonical(id) if (!p.exists()) return null return try { - // Events on disk were valid when written, so interning - // their reconstruction is safe and lets multiple readers - // of the same id share one Event instance. - interner.intern(Event.fromJson(p.readText())) + Event.fromJson(p.readText()) } catch (_: java.nio.file.NoSuchFileException) { null }