From f4b94d6c6a06ab5b614ab2c5c45c6bea6c648a46 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 15:52:21 +0000 Subject: [PATCH] feat(quartz): InterningEventStore now interns accepted events on insert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the inner store accepts an event (insert succeeds), register the caller's instance with the interner so subsequent reads of the same id return the same `===` reference. This means an event that was just inserted and is then queried back resolves to the original in-memory object, not a freshly-deserialized clone. Same in transaction { }: every accepted event is interned after the inner transaction commits. If the body throws or inner rolls back, nothing is interned (accepted list is discarded). The decorator still does not *substitute* the caller's event with a previously-cached one — same-id-different-sig collisions resolve the new event into the cache without overwriting (intern() is first-seen-wins). The caller keeps the instance they passed in. All cache + store + projection tests pass. https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5 --- .../cache/interning/InterningEventStore.kt | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/interning/InterningEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/interning/InterningEventStore.kt index 3e521ee22..d224a17d9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/interning/InterningEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/cache/interning/InterningEventStore.kt @@ -39,11 +39,20 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore * 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. + * Writes ([insert] / [transaction]) forward to the inner store and, + * on success, register the accepted event with the interner. The + * caller's instance becomes canonical for that id (so a later read + * of the same id returns the same `===` reference, until the weak + * ref is collected). The decorator does *not* substitute the + * caller's event for a previously-cached one on the way in — sigs + * may differ across "same id, different decode" cases, and the + * caller's instance is the freshest. + * + * Reads ([query]) pipe results through `interner.intern`, returning + * canonical instances for ids that have a live cached entry. + * + * Out-of-band removals (`delete*`, `deleteExpiredEvents`) pass + * through unchanged. * * The default [interner] is [EventInterner.Default]; pass a fresh * instance for tests or any context that needs isolation. @@ -54,9 +63,31 @@ class InterningEventStore( ) : IEventStore { override val relay: NormalizedRelayUrl? get() = inner.relay - override suspend fun insert(event: Event) = inner.insert(event) + override suspend fun insert(event: Event) { + inner.insert(event) + // Inner accepted (didn't throw) — register the canonical + // instance so subsequent reads share the same reference. + interner.intern(event) + } - override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) = inner.transaction(body) + override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) { + // Capture every accepted event so we can intern after the + // inner transaction commits. If body throws or the inner + // rolls back, `accepted` is discarded with the txn. + val accepted = ArrayList() + inner.transaction { + val innerTxn = this + val wrapped = + object : IEventStore.ITransaction { + override fun insert(event: Event) { + innerTxn.insert(event) + accepted.add(event) + } + } + wrapped.body() + } + for (e in accepted) interner.intern(e) + } @Suppress("UNCHECKED_CAST") override suspend fun query(filter: Filter): List = inner.query(filter).map { interner.intern(it) as T }