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
|
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||||
|
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
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.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
@@ -37,9 +36,8 @@ class EventStore(
|
|||||||
dbName: String? = "events.db",
|
dbName: String? = "events.db",
|
||||||
val relay: NormalizedRelayUrl? = "wss://quartz.local/".normalizeRelayUrl(),
|
val relay: NormalizedRelayUrl? = "wss://quartz.local/".normalizeRelayUrl(),
|
||||||
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||||
val interner: EventInterner = EventInterner.Default,
|
|
||||||
) : IEventStore {
|
) : 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)
|
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.SQLiteConnection
|
||||||
import androidx.sqlite.SQLiteStatement
|
import androidx.sqlite.SQLiteStatement
|
||||||
import com.vitorpamplona.quartz.nip01Core.cache.EventInterner
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||||
@@ -36,7 +35,6 @@ class QueryBuilder(
|
|||||||
val fts: FullTextSearchModule,
|
val fts: FullTextSearchModule,
|
||||||
val hasher: (db: SQLiteConnection) -> TagNameValueHasher,
|
val hasher: (db: SQLiteConnection) -> TagNameValueHasher,
|
||||||
val indexStrategy: IndexingStrategy,
|
val indexStrategy: IndexingStrategy,
|
||||||
val interner: EventInterner = EventInterner.Default,
|
|
||||||
) {
|
) {
|
||||||
// ------------
|
// ------------
|
||||||
// Main methods
|
// Main methods
|
||||||
@@ -234,23 +232,16 @@ class QueryBuilder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
private fun <T : Event> SQLiteStatement.toEvent(): T =
|
||||||
private fun <T : Event> SQLiteStatement.toEvent(): T {
|
EventFactory.create<T>(
|
||||||
val event =
|
getText(0),
|
||||||
EventFactory.create<T>(
|
getText(1),
|
||||||
getText(0),
|
getLong(2),
|
||||||
getText(1),
|
getInt(3),
|
||||||
getLong(2),
|
OptimizedJsonMapper.fromJsonToTagArray(getText(4)),
|
||||||
getInt(3),
|
getText(5),
|
||||||
OptimizedJsonMapper.fromJsonToTagArray(getText(4)),
|
getText(6),
|
||||||
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.toRawEvent() =
|
private fun SQLiteStatement.toRawEvent() =
|
||||||
RawEvent(
|
RawEvent(
|
||||||
|
|||||||
-3
@@ -24,7 +24,6 @@ import androidx.sqlite.SQLiteConnection
|
|||||||
import androidx.sqlite.SQLiteDriver
|
import androidx.sqlite.SQLiteDriver
|
||||||
import androidx.sqlite.SQLiteException
|
import androidx.sqlite.SQLiteException
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
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.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||||
@@ -42,7 +41,6 @@ class SQLiteEventStore(
|
|||||||
val relay: NormalizedRelayUrl? = null,
|
val relay: NormalizedRelayUrl? = null,
|
||||||
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||||
val numReaders: Int = 4,
|
val numReaders: Int = 4,
|
||||||
val interner: EventInterner = EventInterner.Default,
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val DATABASE_VERSION = 2
|
const val DATABASE_VERSION = 2
|
||||||
@@ -70,7 +68,6 @@ class SQLiteEventStore(
|
|||||||
fullTextSearchModule,
|
fullTextSearchModule,
|
||||||
seedModule::hasher,
|
seedModule::hasher,
|
||||||
indexStrategy,
|
indexStrategy,
|
||||||
interner,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val modules =
|
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.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
suspend fun <T : Event> EventStore.assertQuery(
|
suspend fun <T : Event> IEventStore.assertQuery(
|
||||||
expected: T?,
|
expected: T?,
|
||||||
filter: Filter,
|
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>,
|
expected: List<T>,
|
||||||
filter: Filter,
|
filter: Filter,
|
||||||
) {
|
) {
|
||||||
|
|||||||
-7
@@ -20,7 +20,6 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||||
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.cache.EventInterner
|
|
||||||
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -64,12 +63,6 @@ open class BaseDBTest {
|
|||||||
EventStore(
|
EventStore(
|
||||||
dbName = null,
|
dbName = null,
|
||||||
indexStrategy = indexStrategy,
|
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
|
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.AddressableEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
@@ -76,7 +75,6 @@ open class FsEventStore(
|
|||||||
* verification re-canonicalises — so format is purely a UX choice.
|
* verification re-canonicalises — so format is purely a UX choice.
|
||||||
*/
|
*/
|
||||||
private val eventToJson: (Event) -> String = Event::toJson,
|
private val eventToJson: (Event) -> String = Event::toJson,
|
||||||
private val interner: EventInterner = EventInterner.Default,
|
|
||||||
) : IEventStore {
|
) : IEventStore {
|
||||||
private val layout = FsLayout(root)
|
private val layout = FsLayout(root)
|
||||||
private val hasher: TagNameValueHasher
|
private val hasher: TagNameValueHasher
|
||||||
@@ -510,10 +508,7 @@ open class FsEventStore(
|
|||||||
val p = layout.canonical(id)
|
val p = layout.canonical(id)
|
||||||
if (!p.exists()) return null
|
if (!p.exists()) return null
|
||||||
return try {
|
return try {
|
||||||
// Events on disk were valid when written, so interning
|
Event.fromJson(p.readText())
|
||||||
// their reconstruction is safe and lets multiple readers
|
|
||||||
// of the same id share one Event instance.
|
|
||||||
interner.intern(Event.fromJson(p.readText()))
|
|
||||||
} catch (_: java.nio.file.NoSuchFileException) {
|
} catch (_: java.nio.file.NoSuchFileException) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user