refactor(quartz): make EventStoreProjection store-agnostic, NIP-aware

The projection now sits on top of any IEventStore, not just SQLite.
Stores publish a simple `inserts: SharedFlow<Event>` (one event per
successful insert), and the projection itself replays the NIP rules
against that stream:

- NIP-01 supersession with the lexical-id tiebreaker, for both
  replaceables (kind 0/3/10000-19999) and addressables (30000-39999).
  Both now share a single in-place update path.
- NIP-09 deletions, with the original-author check (cross-author
  kind-5s are inert, matching the store).
- NIP-62 right-to-vanish, scoped by the projection's relay arg.
- NIP-40 expiration via a per-projection ticker that drops slots whose
  expiration tag has lapsed.

Out-of-band store mutations (`delete(id)`, `clearDB()`, the periodic
`deleteExpiredEvents()` sweep) are no longer visible to projections —
they're maintenance ops; projections re-seed when their scope is
restarted.

Removed from SQLiteEventStore:
- ChangeLogModule + temp-table + AFTER DELETE trigger.
- StoreChange sealed type and the per-mutation drain/publish path.

Both SQLiteEventStore and FsEventStore now satisfy
`IEventStore.inserts`. FsEventStore.insertLocked returns Boolean so
no-op idempotent retries (canonical already on disk) don't re-publish.

Tests:
- EventStoreProjectionTest moves to `store/projection/`, gains four
  new cases: NIP-01 out-of-order rejection, NIP-09 cross-author
  inertness, NIP-62 cross-author no-op, NIP-40 ticker-driven removal.
- 13/13 projection tests + 232/232 total store tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
This commit is contained in:
Claude
2026-04-29 19:56:00 +00:00
parent d370784f3f
commit c54f34ef08
9 changed files with 614 additions and 530 deletions
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip01Core.store
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 kotlinx.coroutines.flow.SharedFlow
interface IEventStore : AutoCloseable { interface IEventStore : AutoCloseable {
suspend fun insert(event: Event) suspend fun insert(event: Event)
@@ -56,5 +57,23 @@ interface IEventStore : AutoCloseable {
suspend fun deleteExpiredEvents() suspend fun deleteExpiredEvents()
/**
* Stream of events the store accepted into durable storage. One
* emission per successfully inserted event, in commit order.
* Rejected inserts (expired, ephemeral, blocked by tombstone /
* vanish, NIP-01 supersession loser) emit nothing.
*
* Consumed by `EventStoreProjection` to maintain a live view —
* the projection itself replays NIP-01 supersession, NIP-09
* deletion fan-out, NIP-62 vanish cascades, and NIP-40 expiration
* from these events, so stores don't need to publish removals.
*
* Out-of-band removals (`delete(id)`, `delete(filter)`, `clearDB()`,
* `deleteExpiredEvents()`) are not visible on this stream — they're
* maintenance operations and projections survive a missed mutation
* by re-seeding when their scope is restarted.
*/
val inserts: SharedFlow<Event>
override fun close() override fun close()
} }
@@ -0,0 +1,401 @@
/*
* 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.projection
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isAddressable
import com.vitorpamplona.quartz.nip01Core.core.isReplaceable
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
/**
* A reactive projection over any [IEventStore] for a fixed set of
* [filters]. Each visible event is wrapped in a [MutableStateFlow] so
* the UI can collect three different kinds of change with the right
* granularity:
*
* - **Membership** (events arriving or leaving) re-emits a brand new
* [List] from [items]. The list reference is stable while membership
* is unchanged.
* - **In-place replaceable / addressable update** (a new version of
* the same `kind:pubkey:dtag` arrives) updates the existing handle's
* [MutableStateFlow.value] without touching the list. Only collectors
* of that one handle re-render. The list ordering is *not*
* reshuffled when the new version has a later `created_at` — each
* slot remembers the sort key it was inserted with, so updates feel
* like pure value mutations.
* - **Removal** (NIP-09 deletion, NIP-62 vanish, NIP-40 expiration)
* drops the handle from the list.
*
* The seed is materialised by querying the store once at start, after
* which the projection is driven entirely by [IEventStore.inserts] and
* its own expiration ticker. The store is never re-queried on
* mutation, and the projection never asks the store to delete anything
* — it interprets incoming Nostr events itself:
*
* - **NIP-01 supersession.** New replaceable / addressable events
* replace prior ones for the same `kind:pubkey[:dtag]`. The
* NIP-01 lexical-id tiebreaker (`new.id < old.id` when
* `created_at` ties) is honoured.
* - **NIP-09 deletions.** A [DeletionEvent] removes any matching
* handle owned by the same author (for GiftWrap, the recipient).
* Cross-author deletions are inert.
* - **NIP-62 right to vanish.** A [RequestToVanishEvent] whose
* `shouldVanishFrom([relay])` is true drops every handle from the
* same author with `created_at < vanish.created_at`.
* - **NIP-40 expiration.** Events with a past `expiration` tag are
* rejected at insert time. A periodic ticker drops slots whose
* expiration has just lapsed; collectors see the slot disappear.
*
* That's the same set of rules the SQLite / FS stores enforce for
* durability. The duplication is by design — the store enforces them
* on disk so the file isn't corrupt; the projection enforces them in
* memory so the live view stays correct without a re-query per event.
*
* Limit handling: the initial query honours the filter `limit`, and
* we trim to the same cap when an insert pushes the list over. We do
* **not** refill from the store after a deletion — if a deletion
* leaves you under the limit, you stay under the limit until something
* new arrives. That tradeoff matches what callers were already getting
* from `LocalCache.observeEvents`.
*
* Out-of-band store mutations — `delete(id)`, `delete(filter)`,
* `clearDB()`, the periodic `deleteExpiredEvents()` sweep — are not
* visible on [IEventStore.inserts] and won't update an open
* projection. Re-open the projection (e.g. cancel and recreate the
* scope) to pick up an out-of-band change.
*
* Lifecycle: the projection runs a collector + an expiration ticker
* inside [scope]. Cancel the scope (or call [close]) when the screen
* using the projection goes away.
*/
class EventStoreProjection<T : Event>(
private val store: IEventStore,
private val filters: List<Filter>,
private val relay: NormalizedRelayUrl?,
scope: CoroutineScope,
private val expirationTickMs: Long = 30_000L,
private val nowProvider: () -> Long = TimeUtils::now,
) : AutoCloseable {
private val _items = MutableStateFlow<List<MutableStateFlow<T>>>(emptyList())
val items: StateFlow<List<MutableStateFlow<T>>> = _items.asStateFlow()
/** Slots keyed by the *current* event id. Re-keyed when a replaceable / addressable handle takes a new version. */
private val byId = HashMap<HexKey, Slot<T>>()
/**
* Slots keyed by `kind:pubkey:dtag` (or `kind:pubkey:` for plain
* replaceables) for in-place updates and supersession lookups.
*/
private val byStableKey = HashMap<String, Slot<T>>()
/**
* Sorted view of the same slots. The comparator uses each slot's
* frozen sort key (the seed event's `created_at` + `id`), so
* supersession in-place updates never move a slot inside this set.
*/
private val ordered = sortedSetOf(slotComparator<T>())
private val limit: Int? = filters.mapNotNull { it.limit }.maxOrNull()
/** Set when the seed has been written to [items], so callers can suspend until the projection is hot. */
val ready: CompletableDeferred<Unit> = CompletableDeferred()
private val collectorJob: Job =
scope.launch {
seed()
ready.complete(Unit)
store.inserts.collect { event -> apply(event) }
}
private val expirationJob: Job =
scope.launch {
// Sleep first so the seed-time sweep covers the initial
// contents — see [seed].
while (true) {
delay(expirationTickMs)
sweepExpired()
}
}
private suspend fun seed() {
val initial = store.query<T>(filters)
val now = nowProvider()
for (event in initial) {
// The store should already exclude expired rows from the
// result, but it doesn't hurt to skip them here too —
// covers any FS / in-memory store that hasn't run a sweep
// recently.
if (isExpiredAt(event, now)) continue
insertNew(event)
yield()
}
publish()
}
private fun apply(event: Event) {
if (isExpiredAt(event, nowProvider())) return
var changed = false
// Apply NIP-09 / NIP-62 side effects of the event before we
// consider matching the event itself against the filter — a
// deletion event that arrives at the same instant as a
// matching event still removes its targets.
if (event is DeletionEvent) {
if (handleDeletion(event)) changed = true
}
if (event is RequestToVanishEvent && event.shouldVanishFrom(relay)) {
if (handleVanish(event)) changed = true
}
if (filters.any { it.match(event) }) {
if (handleInsert(event)) changed = true
}
if (changed) publish()
}
/**
* Returns true if the event matches the filter and its arrival
* caused membership to change (a fresh slot was added). Returns
* false when the arrival was an in-place supersession update or
* was rejected by the NIP-01 tiebreaker.
*/
@Suppress("UNCHECKED_CAST")
private fun handleInsert(event: Event): Boolean {
val key = stableKey(event)
if (key != null) {
val existing = byStableKey[key]
if (existing != null) {
if (!supersedes(event, existing.flow.value)) return false
// Same address, new winner. Rekey byId and update the
// handle's value in place — list reference stays the
// same; only the handle's collectors re-render.
val previousId = existing.flow.value.id
if (previousId != event.id) {
byId.remove(previousId)
byId[event.id] = existing
}
existing.flow.value = event as T
return false
}
} else if (byId.containsKey(event.id)) {
return false
}
insertNew(event)
return true
}
private fun handleDeletion(deletion: DeletionEvent): Boolean {
var changed = false
// NIP-09: delete by id, but only if the deletion's author owns
// the target. For GiftWrap, the owner is the p-tag recipient.
for (id in deletion.deleteEventIds()) {
val slot = byId[id] ?: continue
val ev = slot.flow.value
val owner = ownerPubKey(ev)
if (owner == deletion.pubKey && removeSlot(slot)) changed = true
}
// NIP-09: delete by address, only original author, only events
// with `created_at <= deletion.created_at`.
for (addr in deletion.deleteAddresses()) {
if (addr.pubKeyHex != deletion.pubKey) continue
val key = stableKey(addr.kind, addr.pubKeyHex, addr.dTag) ?: continue
val slot = byStableKey[key] ?: continue
if (slot.flow.value.createdAt <= deletion.createdAt) {
if (removeSlot(slot)) changed = true
}
}
return changed
}
private fun handleVanish(vanish: RequestToVanishEvent): Boolean {
var changed = false
// Snapshot first because removeSlot mutates byId.
val targets =
byId.values.filter {
val ev = it.flow.value
ownerPubKey(ev) == vanish.pubKey && ev.createdAt < vanish.createdAt
}
for (slot in targets) {
if (removeSlot(slot)) changed = true
}
return changed
}
private fun sweepExpired() {
val now = nowProvider()
val targets = byId.values.filter { isExpiredAt(it.flow.value, now) }
if (targets.isEmpty()) return
var changed = false
for (slot in targets) {
if (removeSlot(slot)) changed = true
}
if (changed) publish()
}
@Suppress("UNCHECKED_CAST")
private fun insertNew(event: Event) {
val slot = Slot(event as T)
byId[event.id] = slot
stableKey(event)?.let { byStableKey[it] = slot }
ordered.add(slot)
val cap = limit ?: return
while (ordered.size > cap) {
val tail = ordered.last()
removeSlot(tail)
}
}
private fun removeSlot(slot: Slot<T>): Boolean {
val removed = byId.remove(slot.flow.value.id) != null
if (!removed) return false
ordered.remove(slot)
stableKey(slot.flow.value)?.let { key ->
// Defensive: only clear the stable-key map if this slot
// still owns it. Could be stale after an addressable rekey
// raced with another insert.
if (byStableKey[key] === slot) byStableKey.remove(key)
}
return true
}
private fun publish() {
_items.value = ordered.map { it.flow }
}
/**
* Stop tracking changes and clear internal state. Idempotent. The
* scope passed to the constructor keeps running; only this
* projection's collector + expiration jobs are cancelled.
*/
override fun close() {
collectorJob.cancel()
expirationJob.cancel()
ordered.clear()
byId.clear()
byStableKey.clear()
_items.value = emptyList()
}
/**
* Internal slot. Each event added to the projection lives inside
* one of these for as long as it survives. The sort key is frozen
* at construction time — supersession in-place updates rewrite
* `flow.value` but never the sort key, so the ordering inside
* [ordered] is stable across updates.
*/
private class Slot<T : Event>(
initial: T,
) {
val sortCreatedAt: Long = initial.createdAt
val sortId: HexKey = initial.id
val flow: MutableStateFlow<T> = MutableStateFlow(initial)
}
companion object {
/**
* The lookup key for replaceable / addressable supersession.
* `null` for regular events (which only collide on event id).
*/
private fun stableKey(event: Event): String? = stableKey(event.kind, event.pubKey, (event as? AddressableEvent)?.dTag())
private fun stableKey(
kind: Int,
pubKeyHex: HexKey,
dTag: String?,
): String? =
when {
kind.isAddressable() -> "$kind:$pubKeyHex:${dTag ?: ""}"
kind.isReplaceable() -> "$kind:$pubKeyHex:"
else -> null
}
/**
* NIP-01 supersession tiebreaker. The new event wins iff:
* - its `created_at` is strictly greater, OR
* - the timestamps tie and its `id` is lexically smaller.
* Otherwise the existing slot keeps its place.
*/
private fun supersedes(
new: Event,
existing: Event,
): Boolean =
when {
new.createdAt > existing.createdAt -> true
new.createdAt < existing.createdAt -> false
else -> new.id < existing.id
}
/**
* Owner pubkey for ownership checks (NIP-09 author match,
* NIP-62 vanish target). For GiftWrap the owner is the p-tag
* recipient; for everything else it's `event.pubKey`.
*/
private fun ownerPubKey(event: Event): HexKey = (event as? GiftWrapEvent)?.recipientPubKey() ?: event.pubKey
private fun isExpiredAt(
event: Event,
now: Long,
): Boolean {
val exp = event.expiration() ?: return false
return exp <= now
}
/**
* created_at DESC, id ASC. The keys are snapshots taken at
* insertion time, so the ordering of a slot never changes
* after it joins the set. Distinct events have distinct ids,
* so no third-key tiebreak is needed.
*/
private fun <T : Event> slotComparator(): Comparator<Slot<T>> =
Comparator { a, b ->
if (a === b) return@Comparator 0
val byTime = b.sortCreatedAt.compareTo(a.sortCreatedAt)
if (byTime != 0) return@Comparator byTime
a.sortId.compareTo(b.sortId)
}
}
}
@@ -1,98 +0,0 @@
/*
* 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.sqlite
import androidx.sqlite.SQLiteConnection
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* Per-connection scratch space that records the id of every row that
* leaves `event_headers`, regardless of why it left.
*
* Why TEMP: the table and trigger are connection-scoped, so they only
* exist on the writer connection where they're installed. Readers never
* see them and never accumulate rows. The data also lives only for the
* lifetime of the connection — exactly the right scope for "ids removed
* since the last drain."
*
* The trigger fires after every delete on `event_headers`, including:
* - the supersession triggers in [ReplaceableModule] / [AddressableModule],
* - the cascade from `event_vanish` in [RightToVanishModule],
* - the explicit deletes in [DeletionRequestModule],
* - [ExpirationModule.deleteExpiredEvents],
* - manual `delete(filter)` / `delete(id)`,
* - `clearDB()`.
*
* The [SQLiteEventStore] drains this log around each unit of work and
* publishes the resulting ids in a [StoreChange].
*/
class ChangeLogModule {
fun installOnWriter(db: SQLiteConnection) {
db.execSQL(
"""
CREATE TEMP TABLE IF NOT EXISTS event_change_log (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL
)
""".trimIndent(),
)
db.execSQL(
"""
CREATE TEMP TRIGGER IF NOT EXISTS event_change_log_on_delete
AFTER DELETE ON event_headers
FOR EACH ROW
BEGIN
INSERT INTO event_change_log (id) VALUES (OLD.id);
END
""".trimIndent(),
)
}
/**
* Reads every id currently logged and clears the log.
*
* Must be called on the writer connection that owns the temp table.
* Callers are expected to be inside the writer mutex (drain happens
* inside or right after the `useWriter { ... }` block); a single
* read-and-clear pair is therefore atomic from the writer's view.
*/
fun drain(db: SQLiteConnection): List<HexKey> {
val ids = ArrayList<HexKey>()
db.prepare("SELECT id FROM event_change_log ORDER BY seq").use { stmt ->
while (stmt.step()) {
ids.add(stmt.getText(0))
}
}
if (ids.isNotEmpty()) {
db.execSQL("DELETE FROM event_change_log")
}
return ids
}
/**
* Drops everything from the log without returning it. Used after a
* rollback so the next successful unit of work starts clean.
*/
fun reset(db: SQLiteConnection) {
db.execSQL("DELETE FROM event_change_log")
}
}
@@ -26,11 +26,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.projection.EventStoreProjection
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
class EventStore( class EventStore(
dbName: String? = "events.db", dbName: String? = "events.db",
relay: NormalizedRelayUrl? = "wss://quartz.local/".normalizeRelayUrl(), val relay: NormalizedRelayUrl? = "wss://quartz.local/".normalizeRelayUrl(),
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
) : IEventStore { ) : IEventStore {
val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relay, indexStrategy) val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relay, indexStrategy)
@@ -67,20 +68,17 @@ class EventStore(
override suspend fun deleteExpiredEvents() = store.deleteExpiredEvents() override suspend fun deleteExpiredEvents() = store.deleteExpiredEvents()
/** override val inserts get() = store.inserts
* Stream of mutations committed to the store. See [SQLiteEventStore.changes].
*/
val changes get() = store.changes
/** /**
* Open a reactive [EventStoreProjection] over the store for * Open a reactive [EventStoreProjection] over this store with
* [filters]. The projection runs inside [scope]; cancel the scope * NIP-62 vanish scoping bound to the store's [relay]. Cancel
* (or call [EventStoreProjection.close]) to release it. * [scope] (or call [EventStoreProjection.close]) to release it.
*/ */
fun <T : Event> observe( fun <T : Event> observe(
filters: List<Filter>, filters: List<Filter>,
scope: CoroutineScope, scope: CoroutineScope,
): EventStoreProjection<T> = EventStoreProjection(store, filters, scope) ): EventStoreProjection<T> = EventStoreProjection(this, filters, relay, scope)
fun <T : Event> observe( fun <T : Event> observe(
filter: Filter, filter: Filter,
@@ -1,247 +0,0 @@
/*
* 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.sqlite
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
/**
* A reactive projection over the [SQLiteEventStore] for a fixed set of
* [filters]. Each visible event is wrapped in a [MutableStateFlow] so
* the UI can collect three different kinds of change with the right
* granularity:
*
* - **Membership** (events arriving or leaving) re-emits a brand new
* [List] from [items]. The list reference is stable while membership
* is unchanged.
* - **In-place addressable update** (a new version of the same
* `kind:pubkey:dtag` arrives) updates the existing handle's
* [MutableStateFlow.value] without touching the list. Only collectors
* of that one handle re-render. The list ordering is *not* reshuffled
* when the new version has a later `created_at` — each slot remembers
* the sort key it was inserted with, so addressable updates feel like
* pure value mutations.
* - **Removal** (NIP-09 deletion, NIP-62 vanish, NIP-40 expiration, or
* a non-addressable event being explicitly deleted) drops the handle
* from the list.
*
* The seed is materialised by running the filters against the store
* once at start. After that, [items] is driven entirely by
* [SQLiteEventStore.changes]; the database is not re-queried on every
* mutation.
*
* Limit handling matches the existing in-memory observables in
* `commons/observables`: the initial query honours the filter `limit`,
* and we trim the list to the same cap when an insert pushes it over.
* We do **not** refill from the DB after a deletion — if a deletion
* leaves you under the limit, you stay under the limit until something
* new arrives. That tradeoff keeps the projection allocation-free per
* mutation and matches what callers were already getting from
* `LocalCache.observeEvents`.
*
* Lifecycle: the projection runs a single coroutine in [scope]. Cancel
* the scope (or call [close]) when the screen using the projection
* goes away. There is no shared state between projections; each one
* keeps its own indexes.
*/
class EventStoreProjection<T : Event>(
private val store: SQLiteEventStore,
private val filters: List<Filter>,
scope: CoroutineScope,
) : AutoCloseable {
private val _items = MutableStateFlow<List<MutableStateFlow<T>>>(emptyList())
val items: StateFlow<List<MutableStateFlow<T>>> = _items.asStateFlow()
/** Slots keyed by the *current* event id. Re-keyed when an addressable handle takes a new version. */
private val byId = HashMap<HexKey, Slot<T>>()
/** Slots keyed by `kind:pubkey:dtag` for in-place addressable updates. */
private val byAddress = HashMap<String, Slot<T>>()
/**
* Sorted view of the same slots. The comparator uses each slot's
* frozen sort key (the seed event's `created_at` + `id`), so an
* addressable update never moves a slot inside this set.
*/
private val ordered = sortedSetOf(slotComparator<T>())
private val limit: Int? = filters.mapNotNull { it.limit }.maxOrNull()
/** Set when the seed has been written to [items], so callers can suspend until the projection is hot. */
val ready: CompletableDeferred<Unit> = CompletableDeferred()
private val job: Job =
scope.launch {
seed()
ready.complete(Unit)
store.changes.collect { change -> apply(change) }
}
private suspend fun seed() {
val initial = store.query<T>(filters)
for (event in initial) {
insertNew(event)
// Cooperate with cancellation on very large seeds.
yield()
}
publish()
}
private fun apply(change: StoreChange) {
var changed = false
// Removals first. A replaceable / addressable supersession
// arrives as `inserted = [new]` plus `removedIds = [oldId]`.
// When the new event is addressable and an existing slot
// already maps that address, [handleInsert] will rekey
// `byId` from the old id to the new id before this loop sees
// the old id — so the lookup here is a no-op for that case
// and the slot stays in place. For non-addressable
// replaceables (kinds 0/3/10000-19999) the old event has a
// different id, no address index, and we genuinely drop it.
for (event in change.inserted) {
if (handleInsert(event)) changed = true
}
for (id in change.removedIds) {
if (handleRemove(id)) changed = true
}
if (changed) publish()
}
@Suppress("UNCHECKED_CAST")
private fun handleInsert(event: Event): Boolean {
if (filters.none { it.match(event) }) return false
if (event is AddressableEvent) {
val key = event.addressTag()
val existing = byAddress[key]
if (existing != null) {
// Same address, new version. Rekey byId from the
// previous event id to the new one and update the
// handle's value in place — list reference does not
// change, only the handle's collectors re-render.
val previousId = existing.flow.value.id
if (previousId != event.id) {
byId.remove(previousId)
byId[event.id] = existing
}
existing.flow.value = event as T
return false
}
} else if (byId.containsKey(event.id)) {
return false
}
insertNew(event)
return true
}
private fun handleRemove(id: HexKey): Boolean {
val slot = byId.remove(id) ?: return false
ordered.remove(slot)
val ev = slot.flow.value
if (ev is AddressableEvent) {
val addr = ev.addressTag()
// Only clear the address index if this slot still owns it.
if (byAddress[addr] === slot) byAddress.remove(addr)
}
return true
}
@Suppress("UNCHECKED_CAST")
private fun insertNew(event: Event) {
val slot = Slot(event as T)
byId[event.id] = slot
if (event is AddressableEvent) byAddress[event.addressTag()] = slot
ordered.add(slot)
val cap = limit ?: return
while (ordered.size > cap) {
val tail = ordered.last()
ordered.remove(tail)
val tailEvent = tail.flow.value
byId.remove(tailEvent.id)
if (tailEvent is AddressableEvent) {
val addr = tailEvent.addressTag()
if (byAddress[addr] === tail) byAddress.remove(addr)
}
}
}
private fun publish() {
_items.value = ordered.map { it.flow }
}
/**
* Stop tracking changes and clear internal state. Idempotent. The
* scope passed to the constructor keeps running; only this
* projection's collector job is cancelled.
*/
override fun close() {
job.cancel()
ordered.clear()
byId.clear()
byAddress.clear()
_items.value = emptyList()
}
/**
* Internal slot. Each event added to the projection lives inside
* one of these for as long as it survives. The sort key is frozen
* at construction time — addressable in-place updates rewrite
* `flow.value` but never the sort key, so the ordering inside
* [ordered] is stable across updates.
*/
private class Slot<T : Event>(
initial: T,
) {
val sortCreatedAt: Long = initial.createdAt
val sortId: HexKey = initial.id
val flow: MutableStateFlow<T> = MutableStateFlow(initial)
}
companion object {
/**
* created_at DESC, id ASC. The keys are snapshots taken at
* insertion time, so the ordering of a slot never changes
* after it joins the set. Distinct events have distinct ids,
* so no third-key tiebreak is needed.
*/
private fun <T : Event> slotComparator(): Comparator<Slot<T>> =
Comparator { a, b ->
if (a === b) return@Comparator 0
val byTime = b.sortCreatedAt.compareTo(a.sortCreatedAt)
if (byTime != 0) return@Comparator byTime
a.sortId.compareTo(b.sortId)
}
}
}
@@ -66,22 +66,18 @@ class SQLiteEventStore(
val deletionModule = DeletionRequestModule(seedModule::hasher) val deletionModule = DeletionRequestModule(seedModule::hasher)
val expirationModule = ExpirationModule() val expirationModule = ExpirationModule()
val rightToVanishModule = RightToVanishModule(seedModule::hasher) val rightToVanishModule = RightToVanishModule(seedModule::hasher)
val changeLogModule = ChangeLogModule()
/** /**
* Stream of mutations committed to the store. One [StoreChange] per * Stream of events the store accepted into durable storage. See
* successful unit of work; rejected inserts and rolled-back * [IEventStore.inserts] for the contract.
* transactions are not emitted. Subscribers see updates in commit
* order. See [EventStoreProjection] for a high-level reactive list
* that consumes this stream.
*/ */
private val _changes = private val _inserts =
MutableSharedFlow<StoreChange>( MutableSharedFlow<Event>(
replay = 0, replay = 0,
extraBufferCapacity = 256, extraBufferCapacity = 256,
onBufferOverflow = BufferOverflow.SUSPEND, onBufferOverflow = BufferOverflow.SUSPEND,
) )
val changes: SharedFlow<StoreChange> = _changes.asSharedFlow() val inserts: SharedFlow<Event> = _inserts.asSharedFlow()
val queryBuilder = val queryBuilder =
QueryBuilder( QueryBuilder(
@@ -139,12 +135,6 @@ class SQLiteEventStore(
setUserVersion(this, DATABASE_VERSION) setUserVersion(this, DATABASE_VERSION)
} }
} }
// After the schema is in place (fresh or already-current
// DBs both reach this point), wire the change log onto
// the writer connection. TEMP table + TEMP trigger are
// connection-scoped, so this runs on the writer only.
changeLogModule.installOnWriter(db)
}, },
) )
} }
@@ -186,23 +176,10 @@ class SQLiteEventStore(
} }
} }
suspend fun clearDB() { suspend fun clearDB() =
pool.useWriter { db -> pool.useWriter { db ->
modules.reversed().forEach { it.deleteAll(db) } modules.reversed().forEach { it.deleteAll(db) }
// The deleteAll cascade fires the change-log trigger for
// every removed event_header row. Drain and publish so
// open projections drop everything they were holding.
publishChange(emptyList(), changeLogModule.drain(db))
} }
}
private suspend fun publishChange(
inserted: List<Event>,
removedIds: List<HexKey>,
) {
if (inserted.isEmpty() && removedIds.isEmpty()) return
_changes.emit(StoreChange(inserted, removedIds))
}
suspend fun vacuum() = suspend fun vacuum() =
pool.useWriter { db -> pool.useWriter { db ->
@@ -237,12 +214,10 @@ class SQLiteEventStore(
db.transaction { db.transaction {
innerInsertEvent(event, this) innerInsertEvent(event, this)
} }
// The transaction either committed or threw. On commit, the // The transaction either committed or threw. On commit the
// change log holds ids superseded by replaceable / addressable // event is durable; emit so subscribed projections see it.
// triggers and any NIP-09 / NIP-62 cascades fired by this // On rollback the throw propagates and we never reach this.
// event's content. On rollback the temp table was rolled back _inserts.emit(event)
// with the transaction, so drain returns empty.
publishChange(listOf(event), changeLogModule.drain(db))
} }
} }
@@ -268,7 +243,9 @@ class SQLiteEventStore(
body() body()
} }
} }
publishChange(txn.accepted, changeLogModule.drain(db)) // Emit each accepted event after the batch commits — the
// projection sees them in the same order they were inserted.
for (e in txn.accepted) _inserts.emit(e)
} }
} }
@@ -308,31 +285,17 @@ class SQLiteEventStore(
suspend fun count(filters: List<Filter>): Int = pool.useReader { queryBuilder.count(filters, it) } suspend fun count(filters: List<Filter>): Int = pool.useReader { queryBuilder.count(filters, it) }
suspend fun delete(filter: Filter) = suspend fun delete(filter: Filter) = pool.useWriter { queryBuilder.delete(filter, it) }
pool.useWriter { db ->
queryBuilder.delete(filter, db)
publishChange(emptyList(), changeLogModule.drain(db))
}
suspend fun delete(filters: List<Filter>) = suspend fun delete(filters: List<Filter>) = pool.useWriter { queryBuilder.delete(filters, it) }
pool.useWriter { db ->
queryBuilder.delete(filters, db)
publishChange(emptyList(), changeLogModule.drain(db))
}
suspend fun delete(id: HexKey): Int = suspend fun delete(id: HexKey): Int =
pool.useWriter { db -> pool.useWriter { db ->
db.execSQL("DELETE FROM event_headers WHERE id = ?", arrayOf(id)) db.execSQL("DELETE FROM event_headers WHERE id = ?", arrayOf(id))
val count = db.changes() db.changes()
publishChange(emptyList(), changeLogModule.drain(db))
count
} }
suspend fun deleteExpiredEvents() = suspend fun deleteExpiredEvents() = pool.useWriter { expirationModule.deleteExpiredEvents(it) }
pool.useWriter { db ->
expirationModule.deleteExpiredEvents(db)
publishChange(emptyList(), changeLogModule.drain(db))
}
fun close() = pool.close() fun close() = pool.close()
} }
@@ -1,57 +0,0 @@
/*
* 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.sqlite
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* One atomic batch of mutations that occurred inside the event store.
*
* The store emits exactly one [StoreChange] per successfully committed
* unit of work — `insertEvent`, a `transaction { ... }` block,
* `delete(...)`, `deleteExpiredEvents()`, or `clearDB()`. Empty changes
* (e.g. an insert that was rejected by a trigger) are not emitted.
*
* `removedIds` covers every row that left `event_headers` during the
* unit of work, regardless of cause: the supersession triggers for
* replaceable / addressable events, NIP-09 deletion fan-out, NIP-62
* vanish cascades, NIP-40 expiration sweeps, and direct `delete(...)`
* calls. They are captured by an `AFTER DELETE` trigger that writes
* `OLD.id` into a per-connection TEMP table.
*
* `inserted` carries the events that survived the writer and are now
* in the database. A replaceable / addressable supersession therefore
* appears as a single change with `inserted = [new]` and
* `removedIds = [oldId]`.
*/
data class StoreChange(
val inserted: List<Event>,
val removedIds: List<HexKey>,
) {
fun isEmpty() = inserted.isEmpty() && removedIds.isEmpty()
fun isNotEmpty() = !isEmpty()
companion object {
val EMPTY = StoreChange(emptyList(), emptyList())
}
}
@@ -18,13 +18,14 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.quartz.nip01Core.store.sqlite package com.vitorpamplona.quartz.nip01Core.store.projection
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
@@ -50,6 +51,7 @@ import kotlin.test.assertTrue
class EventStoreProjectionTest { class EventStoreProjectionTest {
private val signer = NostrSignerSync() private val signer = NostrSignerSync()
private val otherSigner = NostrSignerSync()
private lateinit var store: EventStore private lateinit var store: EventStore
private lateinit var scope: CoroutineScope private lateinit var scope: CoroutineScope
@@ -66,13 +68,6 @@ class EventStoreProjectionTest {
store.close() store.close()
} }
/**
* Wait until [items][EventStoreProjection.items] reaches a state
* for which [predicate] is true. We poll the StateFlow rather than
* collect because the projection only re-emits on membership
* change in-place addressable updates intentionally don't move
* the list reference.
*/
private suspend fun <T : Event> EventStoreProjection<T>.awaitItems( private suspend fun <T : Event> EventStoreProjection<T>.awaitItems(
timeoutMs: Long = 5_000, timeoutMs: Long = 5_000,
predicate: (List<MutableStateFlow<T>>) -> Boolean, predicate: (List<MutableStateFlow<T>>) -> Boolean,
@@ -81,11 +76,6 @@ class EventStoreProjectionTest {
items.first { predicate(it) } items.first { predicate(it) }
} }
/**
* Wait until [flow] reaches [expected]. Used to observe an
* in-place addressable update where the list reference doesn't
* change but the slot's value does.
*/
private suspend fun <T : Event> awaitFlow( private suspend fun <T : Event> awaitFlow(
flow: MutableStateFlow<T>, flow: MutableStateFlow<T>,
timeoutMs: Long = 5_000, timeoutMs: Long = 5_000,
@@ -108,7 +98,6 @@ class EventStoreProjectionTest {
val items = projection.items.value val items = projection.items.value
assertEquals(2, items.size) assertEquals(2, items.size)
// Sorted by created_at DESC.
assertEquals(b.id, items[0].value.id) assertEquals(b.id, items[0].value.id)
assertEquals(a.id, items[1].value.id) assertEquals(a.id, items[1].value.id)
projection.close() projection.close()
@@ -131,12 +120,11 @@ class EventStoreProjectionTest {
val after = projection.awaitItems { it.size == 2 } val after = projection.awaitItems { it.size == 2 }
assertNotSame(before, after, "insert must produce a new list reference") assertNotSame(before, after, "insert must produce a new list reference")
assertEquals(b.id, after[0].value.id) assertEquals(b.id, after[0].value.id)
assertEquals(a.id, after[1].value.id)
projection.close() projection.close()
} }
@Test @Test
fun insertingNonMatchingEventDoesNotChangeList() = fun nonMatchingInsertDoesNotChangeList() =
runBlocking { runBlocking {
val text = signer.sign(TextNoteEvent.build("a", createdAt = 100)) val text = signer.sign(TextNoteEvent.build("a", createdAt = 100))
store.insert(text) store.insert(text)
@@ -145,11 +133,9 @@ class EventStoreProjectionTest {
projection.ready.await() projection.ready.await()
val seed = projection.items.value val seed = projection.items.value
// Metadata kind doesn't match the filter.
val meta = signer.sign(MetadataEvent.createNew("Vitor", createdAt = 200)) val meta = signer.sign(MetadataEvent.createNew("Vitor", createdAt = 200))
store.insert(meta) store.insert(meta)
// Give the projection time to process the change.
delay(150) delay(150)
assertSame(seed, projection.items.value) assertSame(seed, projection.items.value)
projection.close() projection.close()
@@ -176,11 +162,8 @@ class EventStoreProjectionTest {
val v2 = signer.sign(MetadataEvent.createNew("v2", createdAt = time + 1)) val v2 = signer.sign(MetadataEvent.createNew("v2", createdAt = time + 1))
store.insert(v2) store.insert(v2)
// The slot's flow updates...
awaitFlow(slot) { it.id == v2.id } awaitFlow(slot) { it.id == v2.id }
assertSame(seedList, projection.items.value, "replaceable update must not change list reference")
// ...but the list reference is the SAME, and the slot is the SAME instance.
assertSame(seedList, projection.items.value, "addressable replace must not change list reference")
assertSame(slot, projection.items.value[0]) assertSame(slot, projection.items.value[0])
projection.close() projection.close()
} }
@@ -203,16 +186,54 @@ class EventStoreProjectionTest {
) )
projection.ready.await() projection.ready.await()
val seedList = projection.items.value val seedList = projection.items.value
assertEquals(1, seedList.size)
val slot = seedList[0] val slot = seedList[0]
assertEquals(v1.id, slot.value.id)
val v2 = signer.sign(LongTextNoteEvent.build("blog v2", "title", dTag = "blog", createdAt = time + 1)) val v2 = signer.sign(LongTextNoteEvent.build("blog v2", "title", dTag = "blog", createdAt = time + 1))
store.insert(v2) store.insert(v2)
awaitFlow(slot) { it.id == v2.id } awaitFlow(slot) { it.id == v2.id }
assertSame(seedList, projection.items.value, "addressable update must not change list reference") assertSame(seedList, projection.items.value, "addressable update must not change list reference")
assertSame(slot, projection.items.value[0]) projection.close()
}
/**
* Out-of-order arrival: the projection sees the *newer* version
* first (e.g. the relay sent v2 first), then v1. The projection
* must keep v2 in place and not regress to v1 that's the NIP-01
* supersession contract from the projection's side, since the
* store would have rejected v1 anyway.
*/
@Test
fun olderReplaceableArrivingAfterNewerIsRejected() =
runBlocking {
val time = TimeUtils.now()
val v1 = signer.sign(MetadataEvent.createNew("v1", createdAt = time))
val v2 = signer.sign(MetadataEvent.createNew("v2", createdAt = time + 5))
// Seed the projection with v2 before v1 even hits the store
// — by inserting v2 first.
store.insert(v2)
val projection =
store.observe<MetadataEvent>(
Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(v1.pubKey)),
scope,
)
projection.ready.await()
val slot = projection.items.value[0]
assertEquals(v2.id, slot.value.id)
// The store rejects v1 because v2 already won; the
// projection therefore never sees v1 on the inserts
// stream. The slot must still hold v2.
try {
store.insert(v1)
} catch (_: Throwable) {
// expected — store enforces the same rule
}
delay(150)
assertEquals(v2.id, slot.value.id)
projection.close() projection.close()
} }
@@ -224,7 +245,7 @@ class EventStoreProjectionTest {
store.insert(a) store.insert(a)
store.insert(b) store.insert(b)
val projection = store.observe<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope) val projection = store.observe<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
projection.ready.await() projection.ready.await()
assertEquals(2, projection.items.value.size) assertEquals(2, projection.items.value.size)
@@ -236,8 +257,37 @@ class EventStoreProjectionTest {
projection.close() projection.close()
} }
/**
* NIP-09 cross-author deletions are inert. A different signer
* publishing a kind-5 targeting `a` must not drop the slot.
*/
@Test @Test
fun nip62VanishRemovesAllAuthorsEvents() = fun nip09CrossAuthorDeletionIsInert() =
runBlocking {
val a = signer.sign(TextNoteEvent.build("a", createdAt = 100))
store.insert(a)
val projection = store.observe<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
projection.ready.await()
val seed = projection.items.value
assertEquals(1, seed.size)
val foreignDeletion = otherSigner.sign(DeletionEvent.build(listOf(a)))
store.insert(foreignDeletion)
// Give the projection time to process the event.
delay(150)
assertSame(seed, projection.items.value)
assertEquals(
a.id,
projection.items.value[0]
.value.id,
)
projection.close()
}
@Test
fun nip62VanishRemovesAuthorEvents() =
runBlocking { runBlocking {
val time = TimeUtils.now() val time = TimeUtils.now()
val a = signer.sign(TextNoteEvent.build("a", createdAt = time)) val a = signer.sign(TextNoteEvent.build("a", createdAt = time))
@@ -245,7 +295,7 @@ class EventStoreProjectionTest {
store.insert(a) store.insert(a)
store.insert(b) store.insert(b)
val projection = store.observe<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope) val projection = store.observe<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
projection.ready.await() projection.ready.await()
assertEquals(2, projection.items.value.size) assertEquals(2, projection.items.value.size)
@@ -263,8 +313,42 @@ class EventStoreProjectionTest {
projection.close() projection.close()
} }
/**
* NIP-62 only removes events from the same author. A vanish from
* a different author must not touch slots owned by [signer].
*/
@Test @Test
fun nip40ExpirationRemovesSlotOnSweep() = fun nip62OtherAuthorVanishLeavesEventsAlone() =
runBlocking {
val time = TimeUtils.now()
val a = signer.sign(TextNoteEvent.build("a", createdAt = time))
store.insert(a)
val projection = store.observe<Event>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
projection.ready.await()
val seed = projection.items.value
val foreignVanish =
otherSigner.sign(
RequestToVanishEvent.build(
"wss://quartz.local".normalizeRelayUrl(),
createdAt = time + 2,
),
)
store.insert(foreignVanish)
delay(150)
assertSame(seed, projection.items.value)
projection.close()
}
/**
* NIP-40 per-projection ticker: a slot whose `expiration` lapses
* after the projection has loaded should be dropped on the next
* tick, even though the store hasn't run its sweep yet.
*/
@Test
fun nip40ExpirationDroppedByTicker() =
runBlocking { runBlocking {
val time = TimeUtils.now() val time = TimeUtils.now()
val safe = signer.sign(TextNoteEvent.build("safe", createdAt = time) { expiration(time + 100) }) val safe = signer.sign(TextNoteEvent.build("safe", createdAt = time) { expiration(time + 100) })
@@ -272,36 +356,26 @@ class EventStoreProjectionTest {
store.insert(safe) store.insert(safe)
store.insert(short) store.insert(short)
val projection = store.observe<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope) // Drive the ticker frequently so the test doesn't sit idle.
val projection =
EventStoreProjection<Event>(
store,
listOf(Filter(kinds = listOf(TextNoteEvent.KIND))),
relay = null,
scope = scope,
expirationTickMs = 100,
)
projection.ready.await() projection.ready.await()
assertEquals(2, projection.items.value.size) assertEquals(2, projection.items.value.size)
// Wait for the expiration to lapse, then run the sweep. // Wait past the short expiration.
delay(2000) delay(2000)
store.deleteExpiredEvents()
val after = projection.awaitItems { it.size == 1 } val after = projection.awaitItems(timeoutMs = 5_000) { it.size == 1 }
assertEquals(safe.id, after[0].value.id) assertEquals(safe.id, after[0].value.id)
projection.close() projection.close()
} }
@Test
fun manualDeleteByIdRemovesSlot() =
runBlocking {
val a = signer.sign(TextNoteEvent.build("a", createdAt = 100))
store.insert(a)
val projection = store.observe<TextNoteEvent>(Filter(kinds = listOf(TextNoteEvent.KIND)), scope)
projection.ready.await()
assertEquals(1, projection.items.value.size)
store.store.delete(a.id)
val after = projection.awaitItems { it.isEmpty() }
assertTrue(after.isEmpty())
projection.close()
}
@Test @Test
fun limitIsEnforcedOnInsertOverflow() = fun limitIsEnforcedOnInsertOverflow() =
runBlocking { runBlocking {
@@ -318,7 +392,6 @@ class EventStoreProjectionTest {
projection.ready.await() projection.ready.await()
assertEquals(2, projection.items.value.size) assertEquals(2, projection.items.value.size)
// Newer event arrives; it should push the oldest out.
val c = signer.sign(TextNoteEvent.build("c", createdAt = 300)) val c = signer.sign(TextNoteEvent.build("c", createdAt = 300))
store.insert(c) store.insert(c)
@@ -339,7 +412,6 @@ class EventStoreProjectionTest {
projection.ready.await() projection.ready.await()
projection.close() projection.close()
// Subsequent inserts must not surface in the (now empty) projection.
store.insert(signer.sign(TextNoteEvent.build("b", createdAt = 200))) store.insert(signer.sign(TextNoteEvent.build("b", createdAt = 200)))
delay(150) delay(150)
assertTrue(projection.items.value.isEmpty()) assertTrue(projection.items.value.isEmpty())
@@ -36,6 +36,10 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import java.nio.file.FileAlreadyExistsException import java.nio.file.FileAlreadyExistsException
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
@@ -95,19 +99,40 @@ open class FsEventStore(
planner = FsQueryPlanner(layout, hasher) planner = FsQueryPlanner(layout, hasher)
} }
/**
* Stream of events newly persisted by this store. See
* [IEventStore.inserts] for the contract. Events that are no-op
* idempotent retries (canonical already on disk from a previous
* call) are not re-emitted; the original insert already published.
*/
private val _inserts =
MutableSharedFlow<Event>(
replay = 0,
extraBufferCapacity = 256,
onBufferOverflow = BufferOverflow.SUSPEND,
)
override val inserts: SharedFlow<Event> = _inserts.asSharedFlow()
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Insert // Insert
// ------------------------------------------------------------------ // ------------------------------------------------------------------
override suspend fun insert(event: Event) = override suspend fun insert(event: Event) {
lockManager.withWriteLock { val accepted = lockManager.withWriteLock { insertLocked(event) }
insertLocked(event) if (accepted) _inserts.emit(event)
} }
private fun insertLocked(event: Event) { /**
if (event.kind.isEphemeral()) return * Inserts the event under the write lock and returns true iff this
if (isAlreadyExpired(event)) return * call is the one that newly persisted it. Returns false for the
if (isBlockedByTombstone(event)) return * no-op paths (ephemeral, expired, blocked, supersession loser, or
* canonical already on disk from a prior call) so callers can
* decide whether to publish on [inserts].
*/
private fun insertLocked(event: Event): Boolean {
if (event.kind.isEphemeral()) return false
if (isAlreadyExpired(event)) return false
if (isBlockedByTombstone(event)) return false
val slot = slots.slotPathFor(event) val slot = slots.slotPathFor(event)
val existingSlot = slot?.let { slots.readSlot(it) } val existingSlot = slot?.let { slots.readSlot(it) }
@@ -121,7 +146,7 @@ open class FsEventStore(
// (later createdAt, or same createdAt with the lexically smaller // (later createdAt, or same createdAt with the lexically smaller
// id). Matches the ReplaceableModule / AddressableModule trigger // id). Matches the ReplaceableModule / AddressableModule trigger
// condition in SQLite. // condition in SQLite.
return return false
} }
val canonical = layout.canonical(event.id) val canonical = layout.canonical(event.id)
@@ -134,7 +159,7 @@ open class FsEventStore(
} }
if (event is DeletionEvent) processDeletion(event, canonical) if (event is DeletionEvent) processDeletion(event, canonical)
if (event is RequestToVanishEvent) processVanish(event, canonical) if (event is RequestToVanishEvent) processVanish(event, canonical)
return return false
} }
Files.createDirectories(canonical.parent) Files.createDirectories(canonical.parent)
@@ -147,7 +172,7 @@ open class FsEventStore(
// A concurrent writer won the race. Canonical is immutable // A concurrent writer won the race. Canonical is immutable
// so the other copy is equivalent — drop our tmp and return. // so the other copy is equivalent — drop our tmp and return.
Files.deleteIfExists(tmp) Files.deleteIfExists(tmp)
return return false
} }
Files.setLastModifiedTime(canonical, FileTime.from(event.createdAt, TimeUnit.SECONDS)) Files.setLastModifiedTime(canonical, FileTime.from(event.createdAt, TimeUnit.SECONDS))
indexer.link(event, canonical) indexer.link(event, canonical)
@@ -156,6 +181,7 @@ open class FsEventStore(
} }
if (event is DeletionEvent) processDeletion(event, canonical) if (event is DeletionEvent) processDeletion(event, canonical)
if (event is RequestToVanishEvent) processVanish(event, canonical) if (event is RequestToVanishEvent) processVanish(event, canonical)
return true
} catch (t: Throwable) { } catch (t: Throwable) {
Files.deleteIfExists(tmp) Files.deleteIfExists(tmp)
throw t throw t
@@ -263,14 +289,21 @@ open class FsEventStore(
} }
} }
override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) = override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) {
val accepted = ArrayList<Event>()
lockManager.withWriteLock { lockManager.withWriteLock {
val txn = val txn =
object : IEventStore.ITransaction { object : IEventStore.ITransaction {
override fun insert(event: Event) = insertLocked(event) override fun insert(event: Event) {
if (insertLocked(event)) accepted.add(event)
}
} }
txn.body() txn.body()
} }
// Emit each accepted event in order after the lock releases —
// mirrors SQLiteEventStore.transaction.
for (e in accepted) _inserts.emit(e)
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Query // Query