refactor(quartz): intern only on store reads, inject per-store interner
The previous commit interned at Event.fromJson, which is too early — the deserializer can produce events with manipulated id fields that don't match their content. Move interning to the post-validation boundary: events are only canonicalised when read back from durable storage, where they were valid when written. - Revert Event.fromJson to plain OptimizedJsonMapper.fromJson. - Revert ObservableEventStore.insert interning. Substituting the caller's event for a previously-cached one with the same id but potentially different sig was a write-side surprise; we leave the caller's instance alone. - Intern at SQLiteStatement.toEvent (read deserialization). - Intern at FsEventStore.readEvent (FS reads). The interner is also now constructor-injected through every store layer (SQLiteEventStore, EventStore, QueryBuilder, FsEventStore) defaulting to EventInterner.Default. BaseDBTest gives each parallel forEachDB store its own EventInterner — without this, parallel test runs that sign "same id, different sig" events through the shared Default would see the first run's sig leak across all DBs. All 240 store + projection + interner tests pass. https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
This commit is contained in:
@@ -67,7 +67,7 @@ open class Event(
|
||||
fun toJson(): String = OptimizedJsonMapper.toJson(this)
|
||||
|
||||
companion object {
|
||||
fun fromJson(json: String): Event = EventInterner.Default.intern(OptimizedJsonMapper.fromJson(json))
|
||||
fun fromJson(json: String): Event = OptimizedJsonMapper.fromJson(json)
|
||||
|
||||
fun fromJsonOrNull(json: String) =
|
||||
try {
|
||||
|
||||
+3
-1
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.EventInterner
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
@@ -36,8 +37,9 @@ 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)
|
||||
val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relay, indexStrategy, interner = interner)
|
||||
|
||||
override suspend fun insert(event: Event) = store.insertEvent(event)
|
||||
|
||||
|
||||
+19
-10
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
import androidx.sqlite.SQLiteStatement
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.EventInterner
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
@@ -35,6 +36,7 @@ class QueryBuilder(
|
||||
val fts: FullTextSearchModule,
|
||||
val hasher: (db: SQLiteConnection) -> TagNameValueHasher,
|
||||
val indexStrategy: IndexingStrategy,
|
||||
val interner: EventInterner = EventInterner.Default,
|
||||
) {
|
||||
// ------------
|
||||
// Main methods
|
||||
@@ -232,16 +234,23 @@ class QueryBuilder(
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T : Event> SQLiteStatement.toEvent() =
|
||||
EventFactory.create<T>(
|
||||
getText(0),
|
||||
getText(1),
|
||||
getLong(2),
|
||||
getInt(3),
|
||||
OptimizedJsonMapper.fromJsonToTagArray(getText(4)),
|
||||
getText(5),
|
||||
getText(6),
|
||||
)
|
||||
@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 SQLiteStatement.toRawEvent() =
|
||||
RawEvent(
|
||||
|
||||
+3
@@ -25,6 +25,7 @@ import androidx.sqlite.SQLiteDriver
|
||||
import androidx.sqlite.SQLiteException
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.EventInterner
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Kind
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
@@ -41,6 +42,7 @@ 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
|
||||
@@ -68,6 +70,7 @@ class SQLiteEventStore(
|
||||
fullTextSearchModule,
|
||||
seedModule::hasher,
|
||||
indexStrategy,
|
||||
interner,
|
||||
)
|
||||
|
||||
val modules =
|
||||
|
||||
+7
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.EventInterner
|
||||
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -63,6 +64,12 @@ 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.store.fs
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.EventInterner
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isEphemeral
|
||||
@@ -75,6 +76,7 @@ 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
|
||||
@@ -508,7 +510,10 @@ open class FsEventStore(
|
||||
val p = layout.canonical(id)
|
||||
if (!p.exists()) return null
|
||||
return try {
|
||||
Event.fromJson(p.readText())
|
||||
// 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()))
|
||||
} catch (_: java.nio.file.NoSuchFileException) {
|
||||
null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user