Centralize the insert-rejection vocabulary in RejectionReason

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MfwV3xgMSmfxy16ujGPxGW
This commit is contained in:
Claude
2026-08-04 03:48:04 +00:00
parent ba78eb599e
commit b46063713e
4 changed files with 65 additions and 6 deletions
@@ -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)
}
}
@@ -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
}
@@ -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"
}
@@ -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)