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:
Vendored
+87
@@ -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()
|
||||
}
|
||||
+1
-3
@@ -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)
|
||||
|
||||
|
||||
+10
-19
@@ -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(
|
||||
|
||||
-3
@@ -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 =
|
||||
|
||||
+3
-2
@@ -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,
|
||||
) {
|
||||
|
||||
-7
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-6
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user