From b46063713e81a494dcf6867698075eca04ae72bb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 03:48:04 +0000 Subject: [PATCH] Centralize the insert-rejection vocabulary in RejectionReason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every store spelled its rejection reasons inline — the expired-event string was duplicated four times across ObservableEventStore and SQLiteEventStore. RejectionReason now carries the NIP-01 OK machine-readable prefixes plus the standard store reasons, so InsertOutcome.Rejected tallies and OK-frame building see one vocabulary no matter which store produced the outcome. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW --- .../quartz/nip01Core/store/IEventStore.kt | 2 +- .../nip01Core/store/ObservableEventStore.kt | 2 +- .../quartz/nip01Core/store/RejectionReason.kt | 58 +++++++++++++++++++ .../store/sqlite/SQLiteEventStore.kt | 9 +-- 4 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt index 8f77817522..68e1b18fb9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt @@ -122,7 +122,7 @@ interface IEventStore : AutoCloseable { insert(event) InsertOutcome.Accepted } catch (e: Throwable) { - InsertOutcome.Rejected(e.message ?: e::class.simpleName ?: "insert failed") + InsertOutcome.Rejected(e.message ?: e::class.simpleName ?: RejectionReason.INSERT_FAILED) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt index c314c4e3b2..6111f2106e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/ObservableEventStore.kt @@ -114,7 +114,7 @@ class ObservableEventStore( if (event.kind.isEphemeral()) { outcomes[i] = if (event.isExpired()) { - IEventStore.InsertOutcome.Rejected("blocked: Cannot insert an expired event") + IEventStore.InsertOutcome.Rejected(RejectionReason.EXPIRED) } else { IEventStore.InsertOutcome.Accepted } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt new file mode 100644 index 0000000000..a9262e6482 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/RejectionReason.kt @@ -0,0 +1,58 @@ +/* + * 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.store + +/** + * The machine-readable insert-rejection vocabulary, shared by every + * [IEventStore] implementation so the same condition always rejects with the + * same words — a caller tallying [IEventStore.InsertOutcome.Rejected] reasons, + * or a relay building `OK false` frames, must never see two stores spell + * "duplicate" differently. + * + * NIP-01: an `OK false` message SHOULD begin with a single-word + * machine-readable prefix followed by `:`. The `PREFIX_*` constants are that + * vocabulary; the full-sentence constants are the standard reasons the + * built-in stores emit. `replaced:` is not in NIP-01 but is the de-facto + * prefix (strfry and others) for a replaceable event that lost to a stored + * newer version — distinct from `duplicate:` (the exact event is already + * held). + */ +object RejectionReason { + // The NIP-01 machine-readable prefixes. + const val PREFIX_DUPLICATE = "duplicate:" + const val PREFIX_POW = "pow:" + const val PREFIX_BLOCKED = "blocked:" + const val PREFIX_RATE_LIMITED = "rate-limited:" + const val PREFIX_INVALID = "invalid:" + const val PREFIX_RESTRICTED = "restricted:" + const val PREFIX_ERROR = "error:" + + /** De-facto prefix (not in NIP-01) for a stale version of a replaceable/addressable event. */ + const val PREFIX_REPLACED = "replaced:" + + // The standard store reasons. + const val DUPLICATE = "duplicate: already have this event" + const val EXPIRED = "blocked: Cannot insert an expired event" + const val DELETED = "blocked: a deletion event exists" + const val VANISHED = "blocked: a request to vanish event exists" + const val REPLACED = "replaced: a newer version exists" + const val INSERT_FAILED = "error: insert failed" +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 515ca0b916..80e31f58d6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.store.FtsReindexProgress import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.IdAndTime import com.vitorpamplona.quartz.nip01Core.store.RawEvent +import com.vitorpamplona.quartz.nip01Core.store.RejectionReason import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.nip50Search.strippingSearchExtensions @@ -433,7 +434,7 @@ class SQLiteEventStore( } suspend fun insertEvent(event: Event) { - if (event.isExpired()) throw SQLiteException("blocked: Cannot insert an expired event") + if (event.isExpired()) throw SQLiteException(RejectionReason.EXPIRED) if (event.kind.isEphemeral()) return pool.useWriter { db -> @@ -489,7 +490,7 @@ class SQLiteEventStore( delta: LiveIndexDelta?, ): IEventStore.InsertOutcome { if (event.isExpired()) { - return IEventStore.InsertOutcome.Rejected("blocked: Cannot insert an expired event") + return IEventStore.InsertOutcome.Rejected(RejectionReason.EXPIRED) } if (event.kind.isEphemeral()) return IEventStore.InsertOutcome.Accepted @@ -509,7 +510,7 @@ class SQLiteEventStore( // ROLLBACK shouldn't mask the original cause. runCatching { db.execSQL("ROLLBACK TRANSACTION TO SAVEPOINT $sp") } runCatching { db.execSQL("RELEASE SAVEPOINT $sp") } - IEventStore.InsertOutcome.Rejected(e.message ?: e::class.simpleName ?: "insert failed") + IEventStore.InsertOutcome.Rejected(e.message ?: e::class.simpleName ?: RejectionReason.INSERT_FAILED) } } @@ -518,7 +519,7 @@ class SQLiteEventStore( private val delta: LiveIndexDelta?, ) : IEventStore.ITransaction { override fun insert(event: Event) { - if (event.isExpired()) throw SQLiteException("blocked: Cannot insert an expired event") + if (event.isExpired()) throw SQLiteException(RejectionReason.EXPIRED) if (event.kind.isEphemeral()) return innerInsertEvent(event, db, delta)