refactor(quartz): encapsulate interning as InternedEventStore decorator

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
This commit is contained in:
Claude
2026-04-30 14:30:48 +00:00
parent 03e9c9d2b2
commit 3fc4781d12
7 changed files with 102 additions and 40 deletions
@@ -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 <T : Event> query(filter: Filter): List<T> = inner.query<T>(filter).map { interner.intern(it) as T }
@Suppress("UNCHECKED_CAST")
override suspend fun <T : Event> query(filters: List<Filter>): List<T> = inner.query<T>(filters).map { interner.intern(it) as T }
@Suppress("UNCHECKED_CAST")
override suspend fun <T : Event> query(
filter: Filter,
onEach: (T) -> Unit,
) = inner.query<T>(filter) { onEach(interner.intern(it) as T) }
@Suppress("UNCHECKED_CAST")
override suspend fun <T : Event> query(
filters: List<Filter>,
onEach: (T) -> Unit,
) = inner.query<T>(filters) { onEach(interner.intern(it) as T) }
override suspend fun count(filter: Filter): Int = inner.count(filter)
override suspend fun count(filters: List<Filter>): Int = inner.count(filters)
override suspend fun delete(filter: Filter) = inner.delete(filter)
override suspend fun delete(filters: List<Filter>) = inner.delete(filters)
override suspend fun deleteExpiredEvents() = inner.deleteExpiredEvents()
override fun close() = inner.close()
}
@@ -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)
@@ -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 <T : Event> SQLiteStatement.toEvent(): T {
val event =
EventFactory.create<T>(
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 <T : Event> SQLiteStatement.toEvent(): T =
EventFactory.create<T>(
getText(0),
getText(1),
getLong(2),
getInt(3),
OptimizedJsonMapper.fromJsonToTagArray(getText(4)),
getText(5),
getText(6),
)
private fun SQLiteStatement.toRawEvent() =
RawEvent(
@@ -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 =
@@ -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 <T : Event> EventStore.assertQuery(
suspend fun <T : Event> IEventStore.assertQuery(
expected: T?,
filter: Filter,
) {
@@ -40,7 +41,7 @@ suspend fun <T : Event> EventStore.assertQuery(
}
}
suspend fun <T : Event> EventStore.assertQuery(
suspend fun <T : Event> IEventStore.assertQuery(
expected: List<T>,
filter: Filter,
) {
@@ -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(),
)
}
}
@@ -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
}