fix(quartz/sqlite): serialise writes via a Room-style connection pool

androidx.sqlite SQLiteConnection is not thread-safe; SQLiteEventStore
shared a single lazy connection across all callers, so two coroutines
calling insertEvent() at the same time would race on BEGIN IMMEDIATE
and the modules' prepared statements, surfacing as
"cannot start a transaction within a transaction" or SQLITE_MISUSE.

Mirror Room's design: introduce SQLiteConnectionPool with one writer
connection guarded by a coroutine Mutex and N reader connections
handed out via a Channel-as-semaphore (file-backed DBs only; in-memory
DBs share the writer because each ":memory:" connection is a separate
DB). Convert IEventStore + SQLiteEventStore + EventStore + FsEventStore
+ LiveEventStore to suspend, route writes through useWriter and reads
through useReader. RelaySession now launches handleEvent / handleCount
on its scope. CLI Context helpers and StoreCommands.sweepExpired pick
up suspend.

Add ParallelInsertTest to lock the behaviour in: 8 coroutines × 200
inserts, parallel reads alongside writes, transaction batches across
coroutines, and a reopen smoke test all pass against a file-backed DB.

https://claude.ai/code/session_016b5kSSbtDS3Ead6pN3Xqt5
This commit is contained in:
Claude
2026-04-26 13:25:30 +00:00
parent de31d37c01
commit 9fcf85bed0
29 changed files with 2264 additions and 1750 deletions
@@ -173,7 +173,7 @@ class Context(
* publish from", which mirrors `User.outboxRelays()` in the * publish from", which mirrors `User.outboxRelays()` in the
* Android app. * Android app.
*/ */
fun outboxRelays(): Set<NormalizedRelayUrl> = suspend fun outboxRelays(): Set<NormalizedRelayUrl> =
relaysOf(identity.pubKeyHex)?.writeRelaysNorm()?.takeIf { it.isNotEmpty() }?.toSet() relaysOf(identity.pubKeyHex)?.writeRelaysNorm()?.takeIf { it.isNotEmpty() }?.toSet()
?: DefaultNIP65RelaySet ?: DefaultNIP65RelaySet
@@ -181,7 +181,7 @@ class Context(
* DM inbox relays (NIP-17 kind:10050) for this account. Falls back * DM inbox relays (NIP-17 kind:10050) for this account. Falls back
* to [DefaultDMRelayList] when no kind:10050 has been seen. * to [DefaultDMRelayList] when no kind:10050 has been seen.
*/ */
fun inboxRelays(): Set<NormalizedRelayUrl> = suspend fun inboxRelays(): Set<NormalizedRelayUrl> =
dmInboxOf(identity.pubKeyHex)?.relays()?.takeIf { it.isNotEmpty() }?.toSet() dmInboxOf(identity.pubKeyHex)?.relays()?.takeIf { it.isNotEmpty() }?.toSet()
?: DefaultDMRelayList.toSet() ?: DefaultDMRelayList.toSet()
@@ -190,12 +190,12 @@ class Context(
* back to [outboxRelays] when no kind:10051 has been seen — same * back to [outboxRelays] when no kind:10051 has been seen — same
* fallback the Android app uses for KeyPackage discovery. * fallback the Android app uses for KeyPackage discovery.
*/ */
fun keyPackageRelays(): Set<NormalizedRelayUrl> = suspend fun keyPackageRelays(): Set<NormalizedRelayUrl> =
keyPackageRelaysOf(identity.pubKeyHex)?.relays()?.takeIf { it.isNotEmpty() }?.toSet() keyPackageRelaysOf(identity.pubKeyHex)?.relays()?.takeIf { it.isNotEmpty() }?.toSet()
?: outboxRelays() ?: outboxRelays()
/** Union of all three buckets. */ /** Union of all three buckets. */
fun anyRelays(): Set<NormalizedRelayUrl> = outboxRelays() + inboxRelays() + keyPackageRelays() suspend fun anyRelays(): Set<NormalizedRelayUrl> = outboxRelays() + inboxRelays() + keyPackageRelays()
/** /**
* Seed relays for "look up someone we know nothing about" queries — * Seed relays for "look up someone we know nothing about" queries —
@@ -208,7 +208,7 @@ class Context(
* most reliable place to find a stranger's replaceable events even when * most reliable place to find a stranger's replaceable events even when
* we and they have completely disjoint relay configurations. * we and they have completely disjoint relay configurations.
*/ */
fun bootstrapRelays(): Set<NormalizedRelayUrl> = suspend fun bootstrapRelays(): Set<NormalizedRelayUrl> =
buildSet { buildSet {
addAll(anyRelays()) addAll(anyRelays())
addAll(DefaultNIP65RelaySet) addAll(DefaultNIP65RelaySet)
@@ -319,7 +319,7 @@ class Context(
* Every event-arrival path in the CLI funnels through this method * Every event-arrival path in the CLI funnels through this method
* so that [store] is the authoritative cache of what Amy has seen. * so that [store] is the authoritative cache of what Amy has seen.
*/ */
fun verifyAndStore(event: Event): Boolean { suspend fun verifyAndStore(event: Event): Boolean {
if (!event.verify()) { if (!event.verify()) {
System.err.println("[cli] dropped event ${event.id.take(8)} kind=${event.kind} — bad signature") System.err.println("[cli] dropped event ${event.id.take(8)} kind=${event.kind} — bad signature")
return false return false
@@ -342,7 +342,7 @@ class Context(
* this user. Callers that need a network fetch on miss should fall * this user. Callers that need a network fetch on miss should fall
* back to [drain] explicitly — this helper never hits the network. * back to [drain] explicitly — this helper never hits the network.
*/ */
fun profileOf(pubKey: HexKey): MetadataEvent? = suspend fun profileOf(pubKey: HexKey): MetadataEvent? =
store store
.query<Event>( .query<Event>(
Filter(authors = listOf(pubKey), kinds = listOf(MetadataEvent.KIND), limit = 1), Filter(authors = listOf(pubKey), kinds = listOf(MetadataEvent.KIND), limit = 1),
@@ -352,7 +352,7 @@ class Context(
* Latest known kind:10002 advertised relay list (NIP-65) for * Latest known kind:10002 advertised relay list (NIP-65) for
* [pubKey]. `null` when Amy has never seen one. * [pubKey]. `null` when Amy has never seen one.
*/ */
fun relaysOf(pubKey: HexKey): AdvertisedRelayListEvent? = suspend fun relaysOf(pubKey: HexKey): AdvertisedRelayListEvent? =
store store
.query<Event>( .query<Event>(
Filter(authors = listOf(pubKey), kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 1), Filter(authors = listOf(pubKey), kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 1),
@@ -363,7 +363,7 @@ class Context(
* `null` if Amy has never observed one. Useful for follow-graph * `null` if Amy has never observed one. Useful for follow-graph
* lookups without re-hitting relays. * lookups without re-hitting relays.
*/ */
fun contactsOf(pubKey: HexKey): ContactListEvent? = suspend fun contactsOf(pubKey: HexKey): ContactListEvent? =
store store
.query<Event>( .query<Event>(
Filter(authors = listOf(pubKey), kinds = listOf(ContactListEvent.KIND), limit = 1), Filter(authors = listOf(pubKey), kinds = listOf(ContactListEvent.KIND), limit = 1),
@@ -374,7 +374,7 @@ class Context(
* for [pubKey], or `null` if Amy has never observed one. Used by * for [pubKey], or `null` if Amy has never observed one. Used by
* `dm send` to resolve where to deliver a wrap. * `dm send` to resolve where to deliver a wrap.
*/ */
fun dmInboxOf(pubKey: HexKey): ChatMessageRelayListEvent? = suspend fun dmInboxOf(pubKey: HexKey): ChatMessageRelayListEvent? =
store store
.query<Event>( .query<Event>(
Filter(authors = listOf(pubKey), kinds = listOf(ChatMessageRelayListEvent.KIND), limit = 1), Filter(authors = listOf(pubKey), kinds = listOf(ChatMessageRelayListEvent.KIND), limit = 1),
@@ -386,7 +386,7 @@ class Context(
* `marmot key-package check` and `marmot await key-package` to * `marmot key-package check` and `marmot await key-package` to
* locate where the recipient publishes their KeyPackages. * locate where the recipient publishes their KeyPackages.
*/ */
fun keyPackageRelaysOf(pubKey: HexKey): KeyPackageRelayListEvent? = suspend fun keyPackageRelaysOf(pubKey: HexKey): KeyPackageRelayListEvent? =
store store
.query<Event>( .query<Event>(
Filter(authors = listOf(pubKey), kinds = listOf(KeyPackageRelayListEvent.KIND), limit = 1), Filter(authors = listOf(pubKey), kinds = listOf(KeyPackageRelayListEvent.KIND), limit = 1),
@@ -405,7 +405,7 @@ class Context(
* we'll still hand back the old list. Commands that care can drain * we'll still hand back the old list. Commands that care can drain
* (which re-populates the cache) or expose a `--refresh` flag. * (which re-populates the cache) or expose a `--refresh` flag.
*/ */
fun cachedRelayListsOf(pubKey: HexKey): RecipientRelayFetcher.Lists? { suspend fun cachedRelayListsOf(pubKey: HexKey): RecipientRelayFetcher.Lists? {
val dm = dmInboxOf(pubKey) val dm = dmInboxOf(pubKey)
val kp = keyPackageRelaysOf(pubKey) val kp = keyPackageRelaysOf(pubKey)
val nip65 = relaysOf(pubKey) val nip65 = relaysOf(pubKey)
@@ -142,7 +142,7 @@ object FeedCommand {
* an arbitrary `--author` we have no idea where they publish, so we * an arbitrary `--author` we have no idea where they publish, so we
* widen to the bootstrap union. * widen to the bootstrap union.
*/ */
private fun relaysForReadingFeed( private suspend fun relaysForReadingFeed(
ctx: Context, ctx: Context,
mode: String, mode: String,
): Set<NormalizedRelayUrl> = ): Set<NormalizedRelayUrl> =
@@ -226,7 +226,7 @@ object ProfileCommands {
* else's profile, fall back to the bootstrap union so we still find a * else's profile, fall back to the bootstrap union so we still find a
* kind:0 even when our relay set and theirs are disjoint. * kind:0 even when our relay set and theirs are disjoint.
*/ */
private fun relaysForReadingProfile( private suspend fun relaysForReadingProfile(
ctx: Context, ctx: Context,
isSelf: Boolean, isSelf: Boolean,
): Set<NormalizedRelayUrl> = ): Set<NormalizedRelayUrl> =
@@ -137,7 +137,7 @@ object RelayCommands {
} }
} }
private fun list(dataDir: DataDir): Int { private suspend fun list(dataDir: DataDir): Int {
val ctx = Context.open(dataDir) val ctx = Context.open(dataDir)
try { try {
val self = ctx.identity.pubKeyHex val self = ctx.identity.pubKeyHex
@@ -132,7 +132,7 @@ object StoreCommands {
return 0 return 0
} }
private fun sweepExpired(dataDir: DataDir): Int = private suspend fun sweepExpired(dataDir: DataDir): Int =
withStore(dataDir) { store -> withStore(dataDir) { store ->
val expiresAtDir = dataDir.eventsDir.toPath().resolve("idx/expires_at") val expiresAtDir = dataDir.eventsDir.toPath().resolve("idx/expires_at")
val before = countEntries(expiresAtDir) val before = countEntries(expiresAtDir)
@@ -46,7 +46,7 @@ class LiveEventStore(
onBufferOverflow = BufferOverflow.DROP_LATEST, // Default behavior onBufferOverflow = BufferOverflow.DROP_LATEST, // Default behavior
) )
fun insert(event: Event) { suspend fun insert(event: Event) {
store.insert(event) store.insert(event)
newEventStream.tryEmit(event) newEventStream.tryEmit(event)
} }
@@ -70,5 +70,5 @@ class LiveEventStore(
} }
} }
fun count(filters: List<Filter>) = store.count(filters) suspend fun count(filters: List<Filter>) = store.count(filters)
} }
@@ -111,6 +111,36 @@ class RelaySession(
} }
} }
private suspend fun handleEvent(cmd: EventCmd) {
val result = policy.accept(cmd)
if (result is PolicyResult.Rejected) {
send(OkMessage(cmd.event.id, false, result.reason))
return
}
try {
store.insert(cmd.event)
send(OkMessage(cmd.event.id, true, ""))
} catch (e: Exception) {
send(OkMessage(cmd.event.id, false, e.message ?: e::class.simpleName ?: "unkown error"))
}
}
private suspend fun handleCount(cmd: CountCmd) {
val result = policy.accept(cmd)
if (result is PolicyResult.Rejected) {
send(ClosedMessage(cmd.queryId, result.reason))
return
}
// Policy may rewrite filters to match the user's access level.
val filters = (result as PolicyResult.Accepted).cmd.filters
val total = store.count(filters)
send(CountMessage(cmd.queryId, CountResult(total)))
}
// -- NIP-42: AUTH --------------------------------------------------------- // -- NIP-42: AUTH ---------------------------------------------------------
private fun handleAuth(cmd: AuthCmd) { private fun handleAuth(cmd: AuthCmd) {
val result = policy.accept(cmd) val result = policy.accept(cmd)
@@ -164,38 +194,6 @@ class RelaySession(
} }
} }
// -- NIP-01: EVENT --------------------------------------------------------
private fun handleEvent(cmd: EventCmd) {
val result = policy.accept(cmd)
if (result is PolicyResult.Rejected) {
send(OkMessage(cmd.event.id, false, result.reason))
return
}
try {
store.insert(cmd.event)
send(OkMessage(cmd.event.id, true, ""))
} catch (e: Exception) {
send(OkMessage(cmd.event.id, false, e.message ?: e::class.simpleName ?: "unkown error"))
}
}
// -- NIP-45: COUNT --------------------------------------------------------
private fun handleCount(cmd: CountCmd) {
val result = policy.accept(cmd)
if (result is PolicyResult.Rejected) {
send(ClosedMessage(cmd.queryId, result.reason))
return
}
// Policy may rewrite filters to match the user's access level.
val filters = (result as PolicyResult.Accepted).cmd.filters
val total = store.count(filters)
send(CountMessage(cmd.queryId, CountResult(total)))
}
init { init {
policy.onConnect(::send) policy.onConnect(::send)
} }
@@ -24,37 +24,37 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
interface IEventStore : AutoCloseable { interface IEventStore : AutoCloseable {
fun insert(event: Event) suspend fun insert(event: Event)
interface ITransaction { interface ITransaction {
fun insert(event: Event) fun insert(event: Event)
} }
fun transaction(body: ITransaction.() -> Unit) suspend fun transaction(body: ITransaction.() -> Unit)
fun <T : Event> query(filter: Filter): List<T> suspend fun <T : Event> query(filter: Filter): List<T>
fun <T : Event> query(filters: List<Filter>): List<T> suspend fun <T : Event> query(filters: List<Filter>): List<T>
fun <T : Event> query( suspend fun <T : Event> query(
filter: Filter, filter: Filter,
onEach: (T) -> Unit, onEach: (T) -> Unit,
) )
fun <T : Event> query( suspend fun <T : Event> query(
filters: List<Filter>, filters: List<Filter>,
onEach: (T) -> Unit, onEach: (T) -> Unit,
) )
fun count(filter: Filter): Int suspend fun count(filter: Filter): Int
fun count(filters: List<Filter>): Int suspend fun count(filters: List<Filter>): Int
fun delete(filter: Filter) suspend fun delete(filter: Filter)
fun delete(filters: List<Filter>) suspend fun delete(filters: List<Filter>)
fun deleteExpiredEvents() suspend fun deleteExpiredEvents()
override fun close() override fun close()
} }
@@ -34,33 +34,37 @@ class EventStore(
) : IEventStore { ) : IEventStore {
val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relay, indexStrategy) val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relay, indexStrategy)
override fun insert(event: Event) = store.insertEvent(event) override suspend fun insert(event: Event) = store.insertEvent(event)
override fun transaction(body: IEventStore.ITransaction.() -> Unit) = store.transaction(body) override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) = store.transaction(body)
override fun <T : Event> query(filter: Filter) = store.query<T>(filter) override suspend fun <T : Event> query(filter: Filter) = store.query<T>(filter)
override fun <T : Event> query(filters: List<Filter>) = store.query<T>(filters) override suspend fun <T : Event> query(filters: List<Filter>) = store.query<T>(filters)
override fun <T : Event> query( override suspend fun <T : Event> query(
filter: Filter, filter: Filter,
onEach: (T) -> Unit, onEach: (T) -> Unit,
) = store.query(filter, onEach) ) = store.query(filter, onEach)
override fun <T : Event> query( override suspend fun <T : Event> query(
filters: List<Filter>, filters: List<Filter>,
onEach: (T) -> Unit, onEach: (T) -> Unit,
) = store.query(filters, onEach) ) = store.query(filters, onEach)
override fun count(filter: Filter) = store.count(filter) override suspend fun count(filter: Filter) = store.count(filter)
override fun count(filters: List<Filter>) = store.count(filters) override suspend fun count(filters: List<Filter>) = store.count(filters)
override fun delete(filter: Filter) = store.delete(filter) override suspend fun delete(filter: Filter) {
store.delete(filter)
}
override fun delete(filters: List<Filter>) = store.delete(filters) override suspend fun delete(filters: List<Filter>) {
store.delete(filters)
}
override fun deleteExpiredEvents() = store.deleteExpiredEvents() override suspend fun deleteExpiredEvents() = store.deleteExpiredEvents()
override fun close() = store.connection.close() override fun close() = store.close()
} }
@@ -22,10 +22,10 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite
import androidx.sqlite.SQLiteConnection import androidx.sqlite.SQLiteConnection
fun SQLiteEventStore.explainQuery( suspend fun SQLiteEventStore.explainQuery(
sql: String, sql: String,
args: Array<Any> = emptyArray(), args: Array<Any> = emptyArray(),
) = connection.explainQuery(sql, args.map { it.toString() }.toTypedArray()) ): String = pool.useReader { it.explainQuery(sql, args.map { a -> a.toString() }.toTypedArray()) }
fun SQLiteConnection.explainQuery( fun SQLiteConnection.explainQuery(
sql: String, sql: String,
@@ -0,0 +1,135 @@
/*
* 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 androidx.sqlite.SQLiteDriver
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Room-style connection pool for an `androidx.sqlite` database.
*
* `androidx.sqlite.SQLiteConnection` is not thread-safe (same contract as
* `sqlite3*` in the C API): a single connection may only be used by one
* thread at a time. Two coroutines hitting the same connection in parallel
* race on `BEGIN IMMEDIATE` and prepared-statement state, which surfaces
* as `SQLITE_ERROR: cannot start a transaction within a transaction` or
* `SQLITE_MISUSE`.
*
* The pool mirrors what Room does:
*
* - **One writer connection**, guarded by a coroutine [Mutex]. SQLite
* only allows a single writer at the file level anyway, so serialising
* writes here costs nothing — it just queues callers cooperatively
* instead of crashing them.
* - **N reader connections**, handed out from a [Channel] that doubles
* as a semaphore. Under WAL (`PRAGMA journal_mode = WAL`) readers run
* in parallel with the writer and with each other.
*
* For in-memory databases (`dbName == null`) every fresh `:memory:`
* connection opens a *separate* database, so the pool degrades to a
* single-connection mode where readers also acquire the writer mutex.
* That still fixes the parallel-insert crash; it just sacrifices reader
* concurrency for an in-memory store.
*
* Lifecycle:
* 1. `init` opens the writer, runs [onConfigure] on it, then [onMigrate]
* so schema exists before any reader sees the file.
* 2. Readers are opened next and each gets [onConfigure] (PRAGMAs are
* per-connection in SQLite — `journal_mode=WAL` is the only
* database-wide one; subsequent connections inherit it).
* 3. [close] drains the reader channel and closes every connection.
*/
class SQLiteConnectionPool(
val driver: SQLiteDriver,
val dbName: String?,
val numReaders: Int = 4,
val onConfigure: (SQLiteConnection) -> Unit = {},
val onMigrate: (SQLiteConnection) -> Unit = {},
) : AutoCloseable {
private val isInMemory = dbName == null
private val writerMutex = Mutex()
val writer: SQLiteConnection
private val readers: List<SQLiteConnection>
private val readerChannel: Channel<SQLiteConnection>?
init {
writer = openConnection()
onMigrate(writer)
if (isInMemory) {
readers = emptyList()
readerChannel = null
} else {
readers = List(numReaders) { openConnection() }
readerChannel = Channel(numReaders)
readers.forEach { readerChannel.trySend(it) }
}
}
private fun openConnection(): SQLiteConnection {
val db = driver.open(dbName ?: ":memory:")
onConfigure(db)
return db
}
/**
* Acquire the writer connection for the duration of [block]. Other
* writers (and, in the in-memory single-connection mode, readers)
* suspend until the lock is released. Cancellation-aware via the
* coroutine [Mutex].
*/
suspend fun <T> useWriter(block: (SQLiteConnection) -> T): T =
writerMutex.withLock {
block(writer)
}
/**
* Acquire any free reader connection for [block]. With a file-backed
* DB up to [numReaders] readers run in parallel with the writer
* (WAL). With an in-memory DB this falls back to the writer mutex
* because each `:memory:` connection would be a separate database.
*/
suspend fun <T> useReader(block: (SQLiteConnection) -> T): T {
val ch =
readerChannel
?: return writerMutex.withLock { block(writer) }
val conn = ch.receive()
try {
return block(conn)
} finally {
// Capacity == numReaders and we own the conn we received, so
// trySend never fails unless the channel was closed mid-flight
// (in which case the connection is being torn down anyway).
ch.trySend(conn)
}
}
override fun close() {
readerChannel?.close()
readers.forEach { runCatching { it.close() } }
runCatching { writer.close() }
}
}
@@ -34,24 +34,18 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.utils.EventFactory import com.vitorpamplona.quartz.utils.EventFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
class SQLiteEventStore( class SQLiteEventStore(
val driver: SQLiteDriver = BundledSQLiteDriver(), val driver: SQLiteDriver = BundledSQLiteDriver(),
val dbName: String? = "events.db", val dbName: String? = "events.db",
val relay: NormalizedRelayUrl? = null, val relay: NormalizedRelayUrl? = null,
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(), val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
val numReaders: Int = 4,
) { ) {
companion object { companion object {
const val DATABASE_VERSION = 2 const val DATABASE_VERSION = 2
} }
val connection: SQLiteConnection by lazy {
openAndConfigure()
}
val seedModule = SeedModule() val seedModule = SeedModule()
val fullTextSearchModule = FullTextSearchModule() val fullTextSearchModule = FullTextSearchModule()
@@ -89,37 +83,44 @@ class SQLiteEventStore(
fullTextSearchModule, fullTextSearchModule,
) )
private fun openAndConfigure(): SQLiteConnection { val pool: SQLiteConnectionPool by lazy {
val db = driver.open(dbName ?: ":memory:") SQLiteConnectionPool(
driver = driver,
dbName = dbName,
numReaders = numReaders,
onConfigure = { db ->
// 32MB memory cache (per-connection).
db.execSQL("PRAGMA cache_size=-32000;")
// 32MB memory cache // Make sure the FKs are sane (per-connection).
db.execSQL("PRAGMA cache_size=-32000;") db.execSQL("PRAGMA foreign_keys = ON;")
// makes sure the FKs are sane // SQLite implements mutations by appending them to a log,
db.execSQL("PRAGMA foreign_keys = ON;") // which it occasionally compacts into the database. This
// is called Write-Ahead Logging (WAL). Setting it on the
// first connection is enough — `journal_mode` is
// database-wide; subsequent connections inherit it.
db.execSQL("PRAGMA journal_mode = WAL;")
// SQLite implements mutations by appending them to a log, which it occasionally // The DB can be corrupted if the OS shuts down before
// compacts into the database. This is called Write-Ahead Logging (WAL) // sync, which generally doesn't happen on Android.
db.execSQL("PRAGMA journal_mode = WAL;") db.execSQL("PRAGMA synchronous = OFF;")
},
// The DB can be corrupted if the OS is shutdown before sync, which generally onMigrate = { db ->
// doesn't happen on Android val currentVersion = getUserVersion(db)
db.execSQL("PRAGMA synchronous = OFF;") if (currentVersion == 0) {
db.transaction {
val currentVersion = getUserVersion(db) onCreate(this)
if (currentVersion == 0) { setUserVersion(this, DATABASE_VERSION)
db.transaction { }
onCreate(this) } else if (currentVersion < DATABASE_VERSION) {
setUserVersion(this, DATABASE_VERSION) db.transaction {
} onUpgrade(this, currentVersion, DATABASE_VERSION)
} else if (currentVersion < DATABASE_VERSION) { setUserVersion(this, DATABASE_VERSION)
db.transaction { }
onUpgrade(this, currentVersion, DATABASE_VERSION) }
setUserVersion(this, DATABASE_VERSION) },
} )
}
return db
} }
private fun getUserVersion(db: SQLiteConnection): Int = private fun getUserVersion(db: SQLiteConnection): Int =
@@ -159,25 +160,24 @@ class SQLiteEventStore(
} }
} }
fun clearDB() { suspend fun clearDB() =
modules.reversed().forEach { it.deleteAll(connection) } pool.useWriter { db ->
} modules.reversed().forEach { it.deleteAll(db) }
suspend fun vacuum() {
// VACUUM: Rebuilds the database file, reclaiming unused space
// and reducing fragmentation.
withContext(Dispatchers.IO) {
connection.execSQL("VACUUM")
} }
}
suspend fun analyse() { suspend fun vacuum() =
// ANALYZE: Collects statistics about tables and indices pool.useWriter { db ->
// to help the query planner optimize queries. // VACUUM: Rebuilds the database file, reclaiming unused space
withContext(Dispatchers.IO) { // and reducing fragmentation.
connection.execSQL("ANALYZE") db.execSQL("VACUUM")
}
suspend fun analyse() =
pool.useWriter { db ->
// ANALYZE: Collects statistics about tables and indices
// to help the query planner optimize queries.
db.execSQL("ANALYZE")
} }
}
private fun innerInsertEvent( private fun innerInsertEvent(
event: Event, event: Event,
@@ -190,12 +190,14 @@ class SQLiteEventStore(
rightToVanishModule.insert(event, relay, headerId, db) rightToVanishModule.insert(event, relay, headerId, db)
} }
fun insertEvent(event: Event) { suspend fun insertEvent(event: Event) {
if (event.isExpired()) throw SQLiteException("blocked: Cannot insert an expired event") if (event.isExpired()) throw SQLiteException("blocked: Cannot insert an expired event")
if (event.kind.isEphemeral()) return if (event.kind.isEphemeral()) return
connection.transaction { pool.useWriter { db ->
innerInsertEvent(event, this) db.transaction {
innerInsertEvent(event, this)
}
} }
} }
@@ -210,64 +212,65 @@ class SQLiteEventStore(
} }
} }
fun transaction(body: Transaction.() -> Unit) { suspend fun transaction(body: Transaction.() -> Unit) {
connection.transaction { pool.useWriter { db ->
with(Transaction(this)) { db.transaction {
body() with(Transaction(this)) {
body()
}
} }
} }
} }
fun <T : Event> query(filter: Filter): List<T> = queryBuilder.query(filter, connection) suspend fun <T : Event> query(filter: Filter): List<T> = pool.useReader { queryBuilder.query(filter, it) }
fun <T : Event> query(filters: List<Filter>): List<T> = queryBuilder.query(filters, connection) suspend fun <T : Event> query(filters: List<Filter>): List<T> = pool.useReader { queryBuilder.query(filters, it) }
fun <T : Event> query( suspend fun <T : Event> query(
filter: Filter, filter: Filter,
onEach: (T) -> Unit, onEach: (T) -> Unit,
) = queryBuilder.query(filter, connection, onEach) ) = pool.useReader { queryBuilder.query(filter, it, onEach) }
fun <T : Event> query( suspend fun <T : Event> query(
filters: List<Filter>, filters: List<Filter>,
onEach: (T) -> Unit, onEach: (T) -> Unit,
) = queryBuilder.query(filters, connection, onEach) ) = pool.useReader { queryBuilder.query(filters, it, onEach) }
fun rawQuery(filter: Filter): List<RawEvent> = queryBuilder.rawQuery(filter, connection) suspend fun rawQuery(filter: Filter): List<RawEvent> = pool.useReader { queryBuilder.rawQuery(filter, it) }
fun rawQuery(filters: List<Filter>): List<RawEvent> = queryBuilder.rawQuery(filters, connection) suspend fun rawQuery(filters: List<Filter>): List<RawEvent> = pool.useReader { queryBuilder.rawQuery(filters, it) }
fun rawQuery( suspend fun rawQuery(
filter: Filter, filter: Filter,
onEach: (RawEvent) -> Unit, onEach: (RawEvent) -> Unit,
) = queryBuilder.rawQuery(filter, connection, onEach) ) = pool.useReader { queryBuilder.rawQuery(filter, it, onEach) }
fun rawQuery( suspend fun rawQuery(
filters: List<Filter>, filters: List<Filter>,
onEach: (RawEvent) -> Unit, onEach: (RawEvent) -> Unit,
) = queryBuilder.rawQuery(filters, connection, onEach) ) = pool.useReader { queryBuilder.rawQuery(filters, it, onEach) }
fun planQuery(filter: Filter) = queryBuilder.planQuery(filter, seedModule.hasher(connection), connection) suspend fun planQuery(filter: Filter) = pool.useReader { queryBuilder.planQuery(filter, seedModule.hasher(it), it) }
fun planQuery(filters: List<Filter>) = queryBuilder.planQuery(filters, seedModule.hasher(connection), connection) suspend fun planQuery(filters: List<Filter>) = pool.useReader { queryBuilder.planQuery(filters, seedModule.hasher(it), it) }
fun count(filter: Filter): Int = queryBuilder.count(filter, connection) suspend fun count(filter: Filter): Int = pool.useReader { queryBuilder.count(filter, it) }
fun count(filters: List<Filter>): Int = queryBuilder.count(filters, connection) suspend fun count(filters: List<Filter>): Int = pool.useReader { queryBuilder.count(filters, it) }
fun delete(filter: Filter) { suspend fun delete(filter: Filter) = pool.useWriter { queryBuilder.delete(filter, it) }
queryBuilder.delete(filter, connection)
}
fun delete(filters: List<Filter>) { suspend fun delete(filters: List<Filter>) = pool.useWriter { queryBuilder.delete(filters, it) }
queryBuilder.delete(filters, connection)
}
fun delete(id: HexKey): Int { suspend fun delete(id: HexKey): Int =
connection.execSQL("DELETE FROM event_headers WHERE id = ?", arrayOf(id)) pool.useWriter { db ->
return connection.changes() db.execSQL("DELETE FROM event_headers WHERE id = ?", arrayOf(id))
} db.changes()
}
fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(connection) suspend fun deleteExpiredEvents() = pool.useWriter { expirationModule.deleteExpiredEvents(it) }
fun close() = pool.close()
} }
class RawEvent( class RawEvent(
@@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import kotlin.test.assertEquals import kotlin.test.assertEquals
fun <T : Event> EventStore.assertQuery( suspend fun <T : Event> EventStore.assertQuery(
expected: T?, expected: T?,
filter: Filter, filter: Filter,
) { ) {
@@ -40,7 +40,7 @@ fun <T : Event> EventStore.assertQuery(
} }
} }
fun <T : Event> EventStore.assertQuery( suspend fun <T : Event> EventStore.assertQuery(
expected: List<T>, expected: List<T>,
filter: Filter, filter: Filter,
) { ) {
@@ -53,7 +53,7 @@ fun <T : Event> EventStore.assertQuery(
} }
} }
fun <T : Event> SQLiteEventStore.assertQuery( suspend fun <T : Event> SQLiteEventStore.assertQuery(
expected: T?, expected: T?,
filter: Filter, filter: Filter,
) { ) {
@@ -69,7 +69,7 @@ fun <T : Event> SQLiteEventStore.assertQuery(
} }
} }
fun <T : Event> SQLiteEventStore.assertQuery( suspend fun <T : Event> SQLiteEventStore.assertQuery(
expected: List<T>, expected: List<T>,
filter: Filter, filter: Filter,
) { ) {
@@ -307,10 +307,14 @@ class BasicTest : BaseDBTest() {
// modules.forEach { it.create(db) }. Pre-fix, FullTextSearchModule // modules.forEach { it.create(db) }. Pre-fix, FullTextSearchModule
// left dummy_fts3/4/5 tables behind on first probe, so the // left dummy_fts3/4/5 tables behind on first probe, so the
// second create() would throw "already exists". // second create() would throw "already exists".
db.store.modules // Drive the module re-create against the writer connection
.reversed() // (drop + create touches schema, so we need exclusive access).
.forEach { it.drop(db.store.connection) } db.store.pool.useWriter { conn ->
db.store.modules.forEach { it.create(db.store.connection) } db.store.modules
.reversed()
.forEach { it.drop(conn) }
db.store.modules.forEach { it.create(conn) }
}
// After re-creation the store is still usable. // After re-creation the store is still usable.
val note = signer.sign(TextNoteEvent.build("test1")) val note = signer.sign(TextNoteEvent.build("test1"))
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.runBlocking
import kotlin.test.AfterTest import kotlin.test.AfterTest
import kotlin.test.BeforeTest import kotlin.test.BeforeTest
import kotlin.test.Test import kotlin.test.Test
@@ -56,24 +57,26 @@ class LargeDBTests {
} }
@Test @Test
fun insertHeavyEvent() { fun insertHeavyEvent() =
events.first { it.id == "3f34b8cb682307ec11753de4669ce8948e95fd6fb360d79136446c5547fd235e" }.let { event -> runBlocking {
try { events.first { it.id == "3f34b8cb682307ec11753de4669ce8948e95fd6fb360d79136446c5547fd235e" }.let { event ->
db.insert(event) try {
} catch (e: SQLiteException) { db.insert(event)
Log.w("LargeDBTests") { "Error inserting event: ${e.message} for event: ${event.toJson()}" } } catch (e: SQLiteException) {
Log.w("LargeDBTests") { "Error inserting event: ${e.message} for event: ${event.toJson()}" }
}
} }
} }
}
@Test @Test
fun insertDatabase() { fun insertDatabase() =
events.forEach { event -> runBlocking {
try { events.forEach { event ->
db.insert(event) try {
} catch (e: SQLiteException) { db.insert(event)
Log.w("LargeDBTests") { "Error inserting event: ${e.message} for event: ${event.toJson()}" } } catch (e: SQLiteException) {
Log.w("LargeDBTests") { "Error inserting event: ${e.message} for event: ${event.toJson()}" }
}
} }
} }
}
} }
@@ -37,9 +37,9 @@ class QueryAssemblerTest : BaseDBTest() {
val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14" val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14"
val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9" val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9"
fun EventStore.explain(f: Filter) = store.queryBuilder.planQuery(f, hasher, store.connection) suspend fun EventStore.explain(f: Filter) = store.pool.useReader { store.queryBuilder.planQuery(f, hasher, it) }
fun EventStore.explain(f: List<Filter>) = store.queryBuilder.planQuery(f, hasher, store.connection) suspend fun EventStore.explain(f: List<Filter>) = store.pool.useReader { store.queryBuilder.planQuery(f, hasher, it) }
@Test @Test
fun testEmpty() = fun testEmpty() =
@@ -99,7 +99,7 @@ open class FsEventStore(
// Insert // Insert
// ------------------------------------------------------------------ // ------------------------------------------------------------------
override fun insert(event: Event) = override suspend fun insert(event: Event) =
lockManager.withWriteLock { lockManager.withWriteLock {
insertLocked(event) insertLocked(event)
} }
@@ -263,7 +263,7 @@ open class FsEventStore(
} }
} }
override fun transaction(body: IEventStore.ITransaction.() -> Unit) = override suspend fun transaction(body: IEventStore.ITransaction.() -> Unit) =
lockManager.withWriteLock { lockManager.withWriteLock {
val txn = val txn =
object : IEventStore.ITransaction { object : IEventStore.ITransaction {
@@ -277,13 +277,13 @@ open class FsEventStore(
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : Event> query(filter: Filter): List<T> { override suspend fun <T : Event> query(filter: Filter): List<T> {
val out = mutableListOf<T>() val out = mutableListOf<T>()
query<T>(filter) { out.add(it) } query<T>(filter) { out.add(it) }
return out return out
} }
override fun <T : Event> query(filters: List<Filter>): List<T> { override suspend fun <T : Event> query(filters: List<Filter>): List<T> {
val seen = HashSet<HexKey>() val seen = HashSet<HexKey>()
val out = mutableListOf<T>() val out = mutableListOf<T>()
filters.forEach { f -> filters.forEach { f ->
@@ -292,7 +292,7 @@ open class FsEventStore(
return out return out
} }
override fun <T : Event> query( override suspend fun <T : Event> query(
filter: Filter, filter: Filter,
onEach: (T) -> Unit, onEach: (T) -> Unit,
) { ) {
@@ -311,7 +311,7 @@ open class FsEventStore(
} }
} }
override fun <T : Event> query( override suspend fun <T : Event> query(
filters: List<Filter>, filters: List<Filter>,
onEach: (T) -> Unit, onEach: (T) -> Unit,
) { ) {
@@ -321,13 +321,13 @@ open class FsEventStore(
} }
} }
override fun count(filter: Filter): Int { override suspend fun count(filter: Filter): Int {
var n = 0 var n = 0
query<Event>(filter) { n++ } query<Event>(filter) { n++ }
return n return n
} }
override fun count(filters: List<Filter>): Int { override suspend fun count(filters: List<Filter>): Int {
var n = 0 var n = 0
query<Event>(filters) { n++ } query<Event>(filters) { n++ }
return n return n
@@ -343,7 +343,7 @@ open class FsEventStore(
* entire store. This is asymmetric with `query(Filter())` which * entire store. This is asymmetric with `query(Filter())` which
* intentionally returns every event — same contract as `SQLiteEventStore`. * intentionally returns every event — same contract as `SQLiteEventStore`.
*/ */
override fun delete(filter: Filter) = override suspend fun delete(filter: Filter) =
lockManager.withWriteLock { lockManager.withWriteLock {
if (filter.isEmpty()) return@withWriteLock if (filter.isEmpty()) return@withWriteLock
val ids = ArrayList<HexKey>() val ids = ArrayList<HexKey>()
@@ -352,7 +352,7 @@ open class FsEventStore(
} }
/** See [delete] for the empty-filter contract. */ /** See [delete] for the empty-filter contract. */
override fun delete(filters: List<Filter>) = override suspend fun delete(filters: List<Filter>) =
lockManager.withWriteLock { lockManager.withWriteLock {
val nonEmpty = filters.filterNot { it.isEmpty() } val nonEmpty = filters.filterNot { it.isEmpty() }
if (nonEmpty.isEmpty()) return@withWriteLock if (nonEmpty.isEmpty()) return@withWriteLock
@@ -362,7 +362,7 @@ open class FsEventStore(
} }
/** Delete an event by id. Returns 1 if a file was removed, 0 otherwise. */ /** Delete an event by id. Returns 1 if a file was removed, 0 otherwise. */
fun delete(id: HexKey): Int = suspend fun delete(id: HexKey): Int =
lockManager.withWriteLock { lockManager.withWriteLock {
deleteLocked(id) deleteLocked(id)
} }
@@ -408,7 +408,10 @@ open class FsEventStore(
if (parsed.first < event.createdAt) toDelete.add(parsed.second) if (parsed.first < event.createdAt) toDelete.add(parsed.second)
} }
} }
toDelete.forEach { delete(it) } // Already inside the writer lock (insertLocked → processVanish);
// call the locked variant to avoid trying to re-suspend on the
// public `delete(id)` from a non-suspend body.
toDelete.forEach { deleteLocked(it) }
} }
/** /**
@@ -416,7 +419,7 @@ open class FsEventStore(
* filenames, and deletes any entry whose `exp < now`. Matches SQLite's * filenames, and deletes any entry whose `exp < now`. Matches SQLite's
* `expiration < unixepoch()` predicate (note: strict `<`, not `<=`). * `expiration < unixepoch()` predicate (note: strict `<`, not `<=`).
*/ */
override fun deleteExpiredEvents() = override suspend fun deleteExpiredEvents() =
lockManager.withWriteLock { lockManager.withWriteLock {
if (!Files.isDirectory(layout.idxExpiresAt)) return@withWriteLock if (!Files.isDirectory(layout.idxExpiresAt)) return@withWriteLock
val now = now() val now = now()
@@ -67,7 +67,7 @@ internal class FsLockManager(
} }
} }
fun <T> withWriteLock(body: () -> T): T { fun acquireWriteLock() {
inProcessLock.lock() inProcessLock.lock()
try { try {
// Only the outermost re-entry actually touches the file lock. // Only the outermost re-entry actually touches the file lock.
@@ -83,18 +83,36 @@ internal class FsLockManager(
channel = ch channel = ch
fileLock = l fileLock = l
} }
try { } catch (t: Throwable) {
return body() inProcessLock.unlock()
} finally { throw t
if (inProcessLock.holdCount == 1) { }
releaseFileLock() }
}
fun releaseWriteLock() {
try {
if (inProcessLock.holdCount == 1) {
releaseFileLock()
} }
} finally { } finally {
inProcessLock.unlock() inProcessLock.unlock()
} }
} }
/**
* Inline so callers may invoke `suspend` functions inside the lock
* body — needed by [FsEventStore.delete], which calls the suspend
* `query` to enumerate ids before deleting them.
*/
inline fun <T> withWriteLock(body: () -> T): T {
acquireWriteLock()
try {
return body()
} finally {
releaseWriteLock()
}
}
override fun close() { override fun close() {
inProcessLock.lock() inProcessLock.lock()
try { try {
@@ -26,6 +26,7 @@ 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
import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.exists import kotlin.io.path.exists
@@ -86,227 +87,239 @@ class FsDeletionTest {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `kind-5 cascade-deletes a target by id`() { fun `kind-5 cascade-deletes a target by id`() =
val n1 = note("one", 10) runBlocking {
val n2 = note("two", 20) val n1 = note("one", 10)
store.insert(n1) val n2 = note("two", 20)
store.insert(n2) store.insert(n1)
store.insert(n2)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n1), createdAt = 30)) val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n1), createdAt = 30))
store.insert(del) store.insert(del)
assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(ids = listOf(n1.id))).map { it.id }) assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(ids = listOf(n1.id))).map { it.id })
assertEquals(listOf(n2.id), store.query<TextNoteEvent>(Filter(ids = listOf(n2.id))).map { it.id }) assertEquals(listOf(n2.id), store.query<TextNoteEvent>(Filter(ids = listOf(n2.id))).map { it.id })
assertEquals(listOf(del.id), store.query<DeletionEvent>(Filter(ids = listOf(del.id))).map { it.id }) assertEquals(listOf(del.id), store.query<DeletionEvent>(Filter(ids = listOf(del.id))).map { it.id })
} }
@Test @Test
fun `deletion blocks re-insertion of the same id`() { fun `deletion blocks re-insertion of the same id`() =
val n1 = note("one", 10) runBlocking {
store.insert(n1) val n1 = note("one", 10)
store.insert(n1)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n1), createdAt = 30)) val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n1), createdAt = 30))
store.insert(del) store.insert(del)
store.insert(n1) // should be blocked store.insert(n1) // should be blocked
assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(ids = listOf(n1.id))).map { it.id }) assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(ids = listOf(n1.id))).map { it.id })
} }
@Test @Test
fun `deletion by non-author neither cascades nor blocks legitimate re-insertion`() { fun `deletion by non-author neither cascades nor blocks legitimate re-insertion`() =
// other signer authors a note runBlocking {
val theirs = note("not yours", 10, signer = otherSigner) // other signer authors a note
store.insert(theirs) val theirs = note("not yours", 10, signer = otherSigner)
store.insert(theirs)
// Our signer attempts to delete it. // Our signer attempts to delete it.
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(theirs), createdAt = 30)) val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(theirs), createdAt = 30))
store.insert(del) store.insert(del)
// Cascade did NOT run — not our author. // Cascade did NOT run — not our author.
assertEquals(listOf(theirs.id), store.query<TextNoteEvent>(Filter(ids = listOf(theirs.id))).map { it.id }) assertEquals(listOf(theirs.id), store.query<TextNoteEvent>(Filter(ids = listOf(theirs.id))).map { it.id })
// The id tombstone *is* installed (so it can fire if and when a // The id tombstone *is* installed (so it can fire if and when a
// future event with that id is owned by the deletion's author), // future event with that id is owned by the deletion's author),
// but when the legitimate owner deletes the local copy and the // but when the legitimate owner deletes the local copy and the
// event re-arrives from another relay, the tombstone must NOT // event re-arrives from another relay, the tombstone must NOT
// block it — only same-author deletions can block re-insertion. // block it — only same-author deletions can block re-insertion.
// Matches SQLite's `event_tags.pubkey_hash = NEW.pubkey_owner_hash`. // Matches SQLite's `event_tags.pubkey_hash = NEW.pubkey_owner_hash`.
store.delete(theirs.id) store.delete(theirs.id)
store.insert(theirs) store.insert(theirs)
assertEquals(listOf(theirs.id), store.query<TextNoteEvent>(Filter(ids = listOf(theirs.id))).map { it.id }) assertEquals(listOf(theirs.id), store.query<TextNoteEvent>(Filter(ids = listOf(theirs.id))).map { it.id })
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Delete by address (addressable) // Delete by address (addressable)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `kind-5 by address cascades addressable slot`() { fun `kind-5 by address cascades addressable slot`() =
val v1 = article("intro", "draft 1", 10) runBlocking {
val v2 = article("intro", "draft 2", 20) val v1 = article("intro", "draft 1", 10)
store.insert(v1) val v2 = article("intro", "draft 2", 20)
store.insert(v2) store.insert(v1)
store.insert(v2)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v2), createdAt = 30)) val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v2), createdAt = 30))
store.insert(del) store.insert(del)
// Slot cleared, canonical removed, indexes gone. // Slot cleared, canonical removed, indexes gone.
val dHash = FsLayout.sha256Hex("intro") val dHash = FsLayout.sha256Hex("intro")
val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json")
assertFalse(slot.exists(), "addressable slot should be cleared") assertFalse(slot.exists(), "addressable slot should be cleared")
assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))).map { it.id }) assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))).map { it.id })
} }
@Test @Test
fun `newer event at a deleted address may pass the cutoff`() { fun `newer event at a deleted address may pass the cutoff`() =
val v1 = article("intro", "draft 1", 10) runBlocking {
store.insert(v1) val v1 = article("intro", "draft 1", 10)
store.insert(v1)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v1), createdAt = 20)) val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v1), createdAt = 20))
store.insert(del) store.insert(del)
// A newer addressable at the same address should still be accepted. // A newer addressable at the same address should still be accepted.
val v3 = article("intro", "draft 3", 30) val v3 = article("intro", "draft 3", 30)
store.insert(v3) store.insert(v3)
val got = store.query<LongTextNoteEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) val got = store.query<LongTextNoteEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND)))
assertEquals(listOf(v3.id), got.map { it.id }) assertEquals(listOf(v3.id), got.map { it.id })
} }
@Test @Test
fun `older event at a deleted address is blocked by cutoff`() { fun `older event at a deleted address is blocked by cutoff`() =
val v1 = article("intro", "draft 1", 10) runBlocking {
store.insert(v1) val v1 = article("intro", "draft 1", 10)
store.insert(v1)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v1), createdAt = 20)) val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v1), createdAt = 20))
store.insert(del) store.insert(del)
// Attempting to re-insert an event authored earlier than the deletion should fail. // Attempting to re-insert an event authored earlier than the deletion should fail.
val older = article("intro", "even-older", 5) val older = article("intro", "even-older", 5)
store.insert(older) store.insert(older)
assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(ids = listOf(older.id))).map { it.id }) assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(ids = listOf(older.id))).map { it.id })
} }
@Test @Test
fun `equal-timestamp event at a deleted address is blocked`() { fun `equal-timestamp event at a deleted address is blocked`() =
val v = article("intro", "v", 10) runBlocking {
store.insert(v) val v = article("intro", "v", 10)
store.insert(v)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v), createdAt = 15)) val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v), createdAt = 15))
store.insert(del) store.insert(del)
val equal = article("intro", "equal", 15) val equal = article("intro", "equal", 15)
store.insert(equal) store.insert(equal)
assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(ids = listOf(equal.id))).map { it.id }) assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(ids = listOf(equal.id))).map { it.id })
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Multiple deletions: strongest cutoff wins // Multiple deletions: strongest cutoff wins
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `later kind-5 raises the address cutoff`() { fun `later kind-5 raises the address cutoff`() =
val v = article("intro", "v", 10) runBlocking {
store.insert(v) val v = article("intro", "v", 10)
store.insert(v)
val del1 = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v), createdAt = 20)) val del1 = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v), createdAt = 20))
store.insert(del1) store.insert(del1)
val del2Target = article("intro", "v2", 30) // inserted only to give del2 a target val del2Target = article("intro", "v2", 30) // inserted only to give del2 a target
store.insert(del2Target) store.insert(del2Target)
val del2 = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(del2Target), createdAt = 40)) val del2 = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(del2Target), createdAt = 40))
store.insert(del2) store.insert(del2)
// Cutoff should now be 40, so an event at createdAt=35 is blocked. // Cutoff should now be 40, so an event at createdAt=35 is blocked.
val mid = article("intro", "mid", 35) val mid = article("intro", "mid", 35)
store.insert(mid) store.insert(mid)
assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(ids = listOf(mid.id))).map { it.id }) assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(ids = listOf(mid.id))).map { it.id })
} }
@Test @Test
fun `earlier kind-5 does not lower an existing stronger cutoff`() { fun `earlier kind-5 does not lower an existing stronger cutoff`() =
val v1 = article("slug", "v1", 10) runBlocking {
val v2 = article("slug", "v2", 20) val v1 = article("slug", "v1", 10)
store.insert(v1) val v2 = article("slug", "v2", 20)
store.insert(v2) store.insert(v1)
store.insert(v2)
val strongDel = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v2), createdAt = 100)) val strongDel = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v2), createdAt = 100))
store.insert(strongDel) store.insert(strongDel)
// Now insert a weaker (earlier) deletion for the same address. // Now insert a weaker (earlier) deletion for the same address.
val weakTarget = article("slug", "target-for-weak", 30) val weakTarget = article("slug", "target-for-weak", 30)
store.insert(weakTarget) // this passes? No: cutoff=100, target@30 is blocked. Actually we want to store.insert(weakTarget) // this passes? No: cutoff=100, target@30 is blocked. Actually we want to
// construct a DeletionEvent that targets the slug address directly. The simplest way: // construct a DeletionEvent that targets the slug address directly. The simplest way:
val weakDel = val weakDel =
signer.sign<DeletionEvent>( signer.sign<DeletionEvent>(
DeletionEvent.buildAddressOnly(listOf(v1), createdAt = 50), DeletionEvent.buildAddressOnly(listOf(v1), createdAt = 50),
) )
store.insert(weakDel) store.insert(weakDel)
// Cutoff should still be 100 — an event at 60 must still be blocked. // Cutoff should still be 100 — an event at 60 must still be blocked.
val blocked = article("slug", "should-be-blocked", 60) val blocked = article("slug", "should-be-blocked", 60)
store.insert(blocked) store.insert(blocked)
assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(ids = listOf(blocked.id))).map { it.id }) assertEquals(emptyList(), store.query<LongTextNoteEvent>(Filter(ids = listOf(blocked.id))).map { it.id })
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Deletion event itself remains queryable // Deletion event itself remains queryable
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `deletion event itself is indexed and queryable`() { fun `deletion event itself is indexed and queryable`() =
val n = note("x", 10) runBlocking {
store.insert(n) val n = note("x", 10)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n), createdAt = 20)) store.insert(n)
store.insert(del) val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n), createdAt = 20))
store.insert(del)
val byKind = store.query<DeletionEvent>(Filter(kinds = listOf(DeletionEvent.KIND))) val byKind = store.query<DeletionEvent>(Filter(kinds = listOf(DeletionEvent.KIND)))
assertEquals(listOf(del.id), byKind.map { it.id }) assertEquals(listOf(del.id), byKind.map { it.id })
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Tombstone files use hardlinks to the kind-5 canonical // Tombstone files use hardlinks to the kind-5 canonical
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `non-author address deletion does not block legitimate addressable inserts`() { fun `non-author address deletion does not block legitimate addressable inserts`() =
// `otherSigner` (call them Bob) authors an addressable; the runBlocking {
// default `signer` (a stranger relative to Bob) then publishes a // `otherSigner` (call them Bob) authors an addressable; the
// kind-5 with an `a` tag pointing at Bob's address. NIP-09 says // default `signer` (a stranger relative to Bob) then publishes a
// only the address owner may delete it, so the stranger's event // kind-5 with an `a` tag pointing at Bob's address. NIP-09 says
// must NOT install an address tombstone — otherwise Bob couldn't // only the address owner may delete it, so the stranger's event
// publish a new version at the same address. Matches SQLite's // must NOT install an address tombstone — otherwise Bob couldn't
// `event_tags.pubkey_hash = NEW.pubkey_owner_hash` guard. // publish a new version at the same address. Matches SQLite's
val v1 = otherArticle("shared", "v1", 10) // `event_tags.pubkey_hash = NEW.pubkey_owner_hash` guard.
store.insert(v1) val v1 = otherArticle("shared", "v1", 10)
assertEquals(listOf(v1.id), store.query<LongTextNoteEvent>(Filter(ids = listOf(v1.id))).map { it.id }) store.insert(v1)
assertEquals(listOf(v1.id), store.query<LongTextNoteEvent>(Filter(ids = listOf(v1.id))).map { it.id })
val strangerDel = val strangerDel =
signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v1), createdAt = 20)) signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v1), createdAt = 20))
store.insert(strangerDel) store.insert(strangerDel)
// Bob can still publish a newer version at the same address. Since // Bob can still publish a newer version at the same address. Since
// the stranger's deletion was non-authoritative, no addr tombstone // the stranger's deletion was non-authoritative, no addr tombstone
// exists to block. // exists to block.
val v2 = otherArticle("shared", "v2", 30) val v2 = otherArticle("shared", "v2", 30)
store.insert(v2) store.insert(v2)
assertEquals(listOf(v2.id), store.query<LongTextNoteEvent>(Filter(ids = listOf(v2.id))).map { it.id }) assertEquals(listOf(v2.id), store.query<LongTextNoteEvent>(Filter(ids = listOf(v2.id))).map { it.id })
} }
@Test @Test
fun `id tombstone is a hardlink to the kind-5 event`() { fun `id tombstone is a hardlink to the kind-5 event`() =
val n = note("x", 10) runBlocking {
store.insert(n) val n = note("x", 10)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n), createdAt = 20)) store.insert(n)
store.insert(del) val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n), createdAt = 20))
store.insert(del)
val tomb = root.resolve("tombstones/id/${n.id}.json") val tomb = root.resolve("tombstones/id/${n.id}.json")
assertTrue(tomb.exists()) assertTrue(tomb.exists())
val canonical = root.resolve("events/${del.id.substring(0, 2)}/${del.id.substring(2, 4)}/${del.id}.json") val canonical = root.resolve("events/${del.id.substring(0, 2)}/${del.id.substring(2, 4)}/${del.id}.json")
assertEquals( assertEquals(
Files.readAttributes(tomb, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey(), Files.readAttributes(tomb, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey(),
Files.readAttributes(canonical, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey(), Files.readAttributes(canonical, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey(),
"tombstone and kind-5 canonical should share an inode", "tombstone and kind-5 canonical should share an inode",
) )
} }
} }
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.exists import kotlin.io.path.exists
@@ -63,141 +64,152 @@ class FsEventStoreTest {
} }
@Test @Test
fun `insert and query by id round-trips`() { fun `insert and query by id round-trips`() =
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("hello")) runBlocking {
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("hello"))
store.insert(note) store.insert(note)
val got = store.query<TextNoteEvent>(Filter(ids = listOf(note.id))) val got = store.query<TextNoteEvent>(Filter(ids = listOf(note.id)))
assertEquals(1, got.size) assertEquals(1, got.size)
assertEquals(note.id, got[0].id) assertEquals(note.id, got[0].id)
assertEquals(note.content, got[0].content) assertEquals(note.content, got[0].content)
assertEquals(note.sig, got[0].sig) assertEquals(note.sig, got[0].sig)
}
@Test
fun `canonical path uses 2-char sharding`() {
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("shard me"))
store.insert(note)
val shard = root.resolve("events").resolve(note.id.substring(0, 2)).resolve(note.id.substring(2, 4))
val file = shard.resolve("${note.id}.json")
assertTrue(file.exists(), "expected canonical at $file")
}
@Test
fun `query returns empty when nothing inserted`() {
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("missing"))
assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(ids = listOf(note.id))))
}
@Test
fun `delete by id removes the file`() {
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("to-delete"))
store.insert(note)
assertEquals(1, store.count(Filter(ids = listOf(note.id))))
val removed = store.delete(note.id)
assertEquals(1, removed)
assertEquals(0, store.count(Filter(ids = listOf(note.id))))
}
@Test
fun `delete returns 0 when event absent`() {
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("never-inserted"))
assertEquals(0, store.delete(note.id))
}
@Test
fun `delete by filter with ids removes matching events`() {
val a = signer.sign<TextNoteEvent>(TextNoteEvent.build("a"))
val b = signer.sign<TextNoteEvent>(TextNoteEvent.build("b"))
store.insert(a)
store.insert(b)
store.delete(Filter(ids = listOf(a.id)))
assertNull(store.query<TextNoteEvent>(Filter(ids = listOf(a.id))).firstOrNull())
assertEquals(b.id, store.query<TextNoteEvent>(Filter(ids = listOf(b.id))).single().id)
}
@Test
fun `insert of duplicate id is a no-op`() {
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("dup"))
store.insert(note)
store.insert(note) // must not throw; content is immutable anyway
assertEquals(1, store.count(Filter(ids = listOf(note.id))))
}
@Test
fun `ephemeral events are not persisted`() {
// Kind 20_000 is the lowest ephemeral kind; use a bare Event
// constructed inline because TextNoteEvent pins kind=1.
val ephemeral =
signer.sign<com.vitorpamplona.quartz.nip01Core.core.Event>(
createdAt = 1,
kind = 20_000,
tags = emptyArray(),
content = "ghost",
)
store.insert(ephemeral)
assertEquals(0, store.count(Filter(ids = listOf(ephemeral.id))))
}
@Test
fun `ids that share the same 4-char shard both persist`() {
// Find two real events whose ids share the same first 4 hex chars.
// With a random KeyPair per sign, this takes a handful of tries.
var a = signer.sign<TextNoteEvent>(TextNoteEvent.build("a0", createdAt = 1))
var b: TextNoteEvent
var salt = 2L
do {
b = signer.sign<TextNoteEvent>(TextNoteEvent.build("b$salt", createdAt = salt))
salt++
} while (b.id.substring(0, 4) != a.id.substring(0, 4) && salt < 200_000)
if (b.id.substring(0, 4) != a.id.substring(0, 4)) {
// Didn't find a collision cheaply. Fall back to inserting two
// unrelated events and checking they both live under their own
// shards — still verifies basic sharding without flakiness.
b = signer.sign(TextNoteEvent.build("unrelated"))
} }
store.insert(a)
store.insert(b)
assertTrue(store.count(Filter(ids = listOf(a.id))) == 1)
assertTrue(store.count(Filter(ids = listOf(b.id))) == 1)
}
@Test @Test
fun `delete with empty filter is safe`() { fun `canonical path uses 2-char sharding`() =
val a = signer.sign<TextNoteEvent>(TextNoteEvent.build("a", createdAt = 1)) runBlocking {
val b = signer.sign<TextNoteEvent>(TextNoteEvent.build("b", createdAt = 2)) val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("shard me"))
store.insert(a) store.insert(note)
store.insert(b)
// Empty filter: query returns everything, but delete must NOT val shard = root.resolve("events").resolve(note.id.substring(0, 2)).resolve(note.id.substring(2, 4))
// wipe the store. Same safe-by-default contract as SQLiteEventStore. val file = shard.resolve("${note.id}.json")
assertEquals(2, store.count(Filter())) assertTrue(file.exists(), "expected canonical at $file")
store.delete(Filter()) }
assertEquals(2, store.count(Filter()))
@Test
store.delete(listOf(Filter(), Filter())) fun `query returns empty when nothing inserted`() =
assertEquals(2, store.count(Filter())) runBlocking {
} val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("missing"))
assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(ids = listOf(note.id))))
@Test }
fun `staging dir is cleared on init`() {
val staging = root.resolve(".staging") @Test
val leftover = Files.createTempFile(staging, "crash-", ".json") fun `delete by id removes the file`() =
assertTrue(leftover.exists()) runBlocking {
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("to-delete"))
// Reopening the store should sweep the staging dir. store.insert(note)
val reopened = FsEventStore(root) assertEquals(1, store.count(Filter(ids = listOf(note.id))))
try {
assertFalse(leftover.exists(), "staging leftover should be cleared on open") val removed = store.delete(note.id)
} finally { assertEquals(1, removed)
reopened.close() assertEquals(0, store.count(Filter(ids = listOf(note.id))))
}
@Test
fun `delete returns 0 when event absent`() =
runBlocking {
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("never-inserted"))
assertEquals(0, store.delete(note.id))
}
@Test
fun `delete by filter with ids removes matching events`() =
runBlocking {
val a = signer.sign<TextNoteEvent>(TextNoteEvent.build("a"))
val b = signer.sign<TextNoteEvent>(TextNoteEvent.build("b"))
store.insert(a)
store.insert(b)
store.delete(Filter(ids = listOf(a.id)))
assertNull(store.query<TextNoteEvent>(Filter(ids = listOf(a.id))).firstOrNull())
assertEquals(b.id, store.query<TextNoteEvent>(Filter(ids = listOf(b.id))).single().id)
}
@Test
fun `insert of duplicate id is a no-op`() =
runBlocking {
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("dup"))
store.insert(note)
store.insert(note) // must not throw; content is immutable anyway
assertEquals(1, store.count(Filter(ids = listOf(note.id))))
}
@Test
fun `ephemeral events are not persisted`() =
runBlocking {
// Kind 20_000 is the lowest ephemeral kind; use a bare Event
// constructed inline because TextNoteEvent pins kind=1.
val ephemeral =
signer.sign<com.vitorpamplona.quartz.nip01Core.core.Event>(
createdAt = 1,
kind = 20_000,
tags = emptyArray(),
content = "ghost",
)
store.insert(ephemeral)
assertEquals(0, store.count(Filter(ids = listOf(ephemeral.id))))
}
@Test
fun `ids that share the same 4-char shard both persist`() =
runBlocking {
// Find two real events whose ids share the same first 4 hex chars.
// With a random KeyPair per sign, this takes a handful of tries.
var a = signer.sign<TextNoteEvent>(TextNoteEvent.build("a0", createdAt = 1))
var b: TextNoteEvent
var salt = 2L
do {
b = signer.sign<TextNoteEvent>(TextNoteEvent.build("b$salt", createdAt = salt))
salt++
} while (b.id.substring(0, 4) != a.id.substring(0, 4) && salt < 200_000)
if (b.id.substring(0, 4) != a.id.substring(0, 4)) {
// Didn't find a collision cheaply. Fall back to inserting two
// unrelated events and checking they both live under their own
// shards — still verifies basic sharding without flakiness.
b = signer.sign(TextNoteEvent.build("unrelated"))
}
store.insert(a)
store.insert(b)
assertTrue(store.count(Filter(ids = listOf(a.id))) == 1)
assertTrue(store.count(Filter(ids = listOf(b.id))) == 1)
}
@Test
fun `delete with empty filter is safe`() =
runBlocking {
val a = signer.sign<TextNoteEvent>(TextNoteEvent.build("a", createdAt = 1))
val b = signer.sign<TextNoteEvent>(TextNoteEvent.build("b", createdAt = 2))
store.insert(a)
store.insert(b)
// Empty filter: query returns everything, but delete must NOT
// wipe the store. Same safe-by-default contract as SQLiteEventStore.
assertEquals(2, store.count(Filter()))
store.delete(Filter())
assertEquals(2, store.count(Filter()))
store.delete(listOf(Filter(), Filter()))
assertEquals(2, store.count(Filter()))
}
@Test
fun `staging dir is cleared on init`() =
runBlocking {
val staging = root.resolve(".staging")
val leftover = Files.createTempFile(staging, "crash-", ".json")
assertTrue(leftover.exists())
// Reopening the store should sweep the staging dir.
val reopened = FsEventStore(root)
try {
assertFalse(leftover.exists(), "staging leftover should be cleared on open")
} finally {
reopened.close()
}
} }
}
} }
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.exists import kotlin.io.path.exists
@@ -54,63 +55,65 @@ class FsEventToJsonTest {
} }
@Test @Test
fun `default formatter writes compact JSON one line`() { fun `default formatter writes compact JSON one line`() =
val store = FsEventStore(root) runBlocking {
try { val store = FsEventStore(root)
val n = try {
signer.sign<TextNoteEvent>( val n =
TextNoteEvent.build("hello", createdAt = 100), signer.sign<TextNoteEvent>(
) TextNoteEvent.build("hello", createdAt = 100),
store.insert(n) )
val canonical = store.insert(n)
root val canonical =
.resolve("events") root
.resolve(n.id.substring(0, 2)) .resolve("events")
.resolve(n.id.substring(2, 4)) .resolve(n.id.substring(0, 2))
.resolve("${n.id}.json") .resolve(n.id.substring(2, 4))
val raw = canonical.readText() .resolve("${n.id}.json")
assertEquals(raw.trim(), raw, "compact form has no trailing whitespace") val raw = canonical.readText()
assertTrue(!raw.contains('\n'), "compact form is single-line") assertEquals(raw.trim(), raw, "compact form has no trailing whitespace")
} finally { assertTrue(!raw.contains('\n'), "compact form is single-line")
store.close() } finally {
store.close()
}
} }
}
@Test @Test
fun `pretty formatter writes multi-line indented JSON and round-trips`() { fun `pretty formatter writes multi-line indented JSON and round-trips`() =
val store = runBlocking {
FsEventStore( val store =
root, FsEventStore(
eventToJson = JacksonMapper::toJsonPretty, root,
) eventToJson = JacksonMapper::toJsonPretty,
try {
val n =
signer.sign<TextNoteEvent>(
TextNoteEvent.build("hello", createdAt = 100),
) )
store.insert(n) try {
val canonical = val n =
root signer.sign<TextNoteEvent>(
.resolve("events") TextNoteEvent.build("hello", createdAt = 100),
.resolve(n.id.substring(0, 2)) )
.resolve(n.id.substring(2, 4)) store.insert(n)
.resolve("${n.id}.json") val canonical =
val raw = canonical.readText() root
assertTrue(raw.contains('\n'), "pretty form is multi-line") .resolve("events")
assertTrue(raw.contains("\"id\""), "field labels survive pretty print") .resolve(n.id.substring(0, 2))
.resolve(n.id.substring(2, 4))
.resolve("${n.id}.json")
val raw = canonical.readText()
assertTrue(raw.contains('\n'), "pretty form is multi-line")
assertTrue(raw.contains("\"id\""), "field labels survive pretty print")
// Round-trip: parsing pretty output back must produce the same event. // Round-trip: parsing pretty output back must produce the same event.
val reparsed = Event.fromJson(raw) val reparsed = Event.fromJson(raw)
assertEquals(n.id, reparsed.id) assertEquals(n.id, reparsed.id)
assertEquals(n.content, reparsed.content) assertEquals(n.content, reparsed.content)
assertEquals(n.sig, reparsed.sig) assertEquals(n.sig, reparsed.sig)
// And the store can read it back through its own API. // And the store can read it back through its own API.
val got = store.query<TextNoteEvent>(Filter(ids = listOf(n.id))) val got = store.query<TextNoteEvent>(Filter(ids = listOf(n.id)))
assertEquals(1, got.size) assertEquals(1, got.size)
assertEquals(n.id, got[0].id) assertEquals(n.id, got[0].id)
} finally { } finally {
store.close() store.close()
}
} }
}
} }
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.exists import kotlin.io.path.exists
@@ -81,128 +82,136 @@ class FsExpirationTest {
) )
@Test @Test
fun `event with future expiration is accepted and indexed`() { fun `event with future expiration is accepted and indexed`() =
clockNow = 1_000 runBlocking {
val e = expiringNote("future", createdAt = 500, expiresAt = 2_000) clockNow = 1_000
store.insert(e) val e = expiringNote("future", createdAt = 500, expiresAt = 2_000)
store.insert(e)
assertEquals(listOf(e.id), store.query<Event>(Filter(ids = listOf(e.id))).map { it.id }) assertEquals(listOf(e.id), store.query<Event>(Filter(ids = listOf(e.id))).map { it.id })
val expIdx = root.resolve("idx/expires_at") val expIdx = root.resolve("idx/expires_at")
val entries = expIdx.listDirectoryEntries().map { it.fileName.toString() } val entries = expIdx.listDirectoryEntries().map { it.fileName.toString() }
assertEquals(1, entries.size, "expires_at index should hold exactly one entry") assertEquals(1, entries.size, "expires_at index should hold exactly one entry")
assertTrue(entries.single().endsWith("-${e.id}")) assertTrue(entries.single().endsWith("-${e.id}"))
assertTrue(entries.single().startsWith("0000002000"), "filename should be padded expiration ts") assertTrue(entries.single().startsWith("0000002000"), "filename should be padded expiration ts")
} }
@Test @Test
fun `event already expired at insert time is rejected`() { fun `event already expired at insert time is rejected`() =
clockNow = 5_000 runBlocking {
val e = expiringNote("dead-on-arrival", createdAt = 1_000, expiresAt = 4_000) clockNow = 5_000
store.insert(e) val e = expiringNote("dead-on-arrival", createdAt = 1_000, expiresAt = 4_000)
store.insert(e)
assertEquals(emptyList(), store.query<Event>(Filter(ids = listOf(e.id))).map { it.id }) assertEquals(emptyList(), store.query<Event>(Filter(ids = listOf(e.id))).map { it.id })
assertFalse(store.hasCanonical(e.id)) assertFalse(store.hasCanonical(e.id))
} }
@Test @Test
fun `event with expiration equal to now is rejected (parity with SQLite trigger)`() { fun `event with expiration equal to now is rejected (parity with SQLite trigger)`() =
clockNow = 5_000 runBlocking {
val e = expiringNote("just-now", createdAt = 1_000, expiresAt = 5_000) clockNow = 5_000
store.insert(e) val e = expiringNote("just-now", createdAt = 1_000, expiresAt = 5_000)
store.insert(e)
assertFalse(store.hasCanonical(e.id), "exp == now should be rejected (SQLite uses <=)") assertFalse(store.hasCanonical(e.id), "exp == now should be rejected (SQLite uses <=)")
} }
@Test @Test
fun `non-positive expiration is ignored`() { fun `non-positive expiration is ignored`() =
clockNow = 5_000 runBlocking {
val zero = expiringNote("zero", createdAt = 1, expiresAt = 0) clockNow = 5_000
val neg = expiringNote("neg", createdAt = 2, expiresAt = -1) val zero = expiringNote("zero", createdAt = 1, expiresAt = 0)
store.insert(zero) val neg = expiringNote("neg", createdAt = 2, expiresAt = -1)
store.insert(neg) store.insert(zero)
store.insert(neg)
assertTrue(store.hasCanonical(zero.id)) assertTrue(store.hasCanonical(zero.id))
assertTrue(store.hasCanonical(neg.id)) assertTrue(store.hasCanonical(neg.id))
// And nothing in idx/expires_at. // And nothing in idx/expires_at.
val expIdx = root.resolve("idx/expires_at") val expIdx = root.resolve("idx/expires_at")
assertEquals(0, expIdx.listDirectoryEntries().size, "non-positive exp should not be indexed") assertEquals(0, expIdx.listDirectoryEntries().size, "non-positive exp should not be indexed")
} }
@Test @Test
fun `deleteExpiredEvents sweeps everything past now`() { fun `deleteExpiredEvents sweeps everything past now`() =
clockNow = 1_000 runBlocking {
val a = expiringNote("a", createdAt = 100, expiresAt = 500) // already expired clockNow = 1_000
val b = expiringNote("b", createdAt = 200, expiresAt = 999) // expired in past val a = expiringNote("a", createdAt = 100, expiresAt = 500) // already expired
val c = expiringNote("c", createdAt = 300, expiresAt = 2_000) // still alive val b = expiringNote("b", createdAt = 200, expiresAt = 999) // expired in past
val c = expiringNote("c", createdAt = 300, expiresAt = 2_000) // still alive
// Insert at a fake earlier "now" so all three pass the insert guard. // Insert at a fake earlier "now" so all three pass the insert guard.
clockNow = 99 clockNow = 99
store.insert(a) store.insert(a)
store.insert(b) store.insert(b)
store.insert(c) store.insert(c)
// Advance the clock and sweep. // Advance the clock and sweep.
clockNow = 1_000 clockNow = 1_000
store.deleteExpiredEvents() store.deleteExpiredEvents()
assertFalse(store.hasCanonical(a.id), "a should be swept") assertFalse(store.hasCanonical(a.id), "a should be swept")
assertFalse(store.hasCanonical(b.id), "b should be swept") assertFalse(store.hasCanonical(b.id), "b should be swept")
assertTrue(store.hasCanonical(c.id), "c should survive") assertTrue(store.hasCanonical(c.id), "c should survive")
} }
@Test @Test
fun `sweep uses strict less-than parity with SQLite`() { fun `sweep uses strict less-than parity with SQLite`() =
// SQLite trigger: WHERE NEW.expiration <= unixepoch() (insert) runBlocking {
// SQLite sweep: WHERE expiration < unixepoch() (delete) // SQLite trigger: WHERE NEW.expiration <= unixepoch() (insert)
// Insert-time uses inclusive <=, sweep uses strict <. // SQLite sweep: WHERE expiration < unixepoch() (delete)
clockNow = 50 // Insert-time uses inclusive <=, sweep uses strict <.
val onTheTick = expiringNote("equal", createdAt = 10, expiresAt = 100) clockNow = 50
store.insert(onTheTick) val onTheTick = expiringNote("equal", createdAt = 10, expiresAt = 100)
store.insert(onTheTick)
clockNow = 100 // exp == now → sweep keeps it clockNow = 100 // exp == now → sweep keeps it
store.deleteExpiredEvents() store.deleteExpiredEvents()
assertTrue(store.hasCanonical(onTheTick.id), "exp == now should NOT be swept") assertTrue(store.hasCanonical(onTheTick.id), "exp == now should NOT be swept")
clockNow = 101 clockNow = 101
store.deleteExpiredEvents() store.deleteExpiredEvents()
assertFalse(store.hasCanonical(onTheTick.id), "exp < now should be swept") assertFalse(store.hasCanonical(onTheTick.id), "exp < now should be swept")
} }
@Test @Test
fun `sweep removes index entries too`() { fun `sweep removes index entries too`() =
clockNow = 50 runBlocking {
val e = expiringNote("x", createdAt = 1, expiresAt = 100) clockNow = 50
store.insert(e) val e = expiringNote("x", createdAt = 1, expiresAt = 100)
store.insert(e)
val expIdx = root.resolve("idx/expires_at") val expIdx = root.resolve("idx/expires_at")
assertEquals(1, expIdx.listDirectoryEntries().size) assertEquals(1, expIdx.listDirectoryEntries().size)
clockNow = 1_000 clockNow = 1_000
store.deleteExpiredEvents() store.deleteExpiredEvents()
assertEquals(0, expIdx.listDirectoryEntries().size, "expires_at entry should be unlinked") assertEquals(0, expIdx.listDirectoryEntries().size, "expires_at entry should be unlinked")
// Author + kind index entries also gone. // Author + kind index entries also gone.
val authorDir = root.resolve("idx/author/${signer.pubKey}") val authorDir = root.resolve("idx/author/${signer.pubKey}")
if (authorDir.exists()) assertEquals(0, authorDir.listDirectoryEntries().size) if (authorDir.exists()) assertEquals(0, authorDir.listDirectoryEntries().size)
} }
@Test @Test
fun `events without expiration are unaffected by sweep`() { fun `events without expiration are unaffected by sweep`() =
clockNow = 100 runBlocking {
val plain = clockNow = 100
signer.sign<Event>( val plain =
createdAt = 50, signer.sign<Event>(
kind = 1, createdAt = 50,
tags = emptyArray(), kind = 1,
content = "plain", tags = emptyArray(),
) content = "plain",
store.insert(plain) )
store.insert(plain)
clockNow = 1_000_000 clockNow = 1_000_000
store.deleteExpiredEvents() store.deleteExpiredEvents()
assertTrue(store.hasCanonical(plain.id)) assertTrue(store.hasCanonical(plain.id))
} }
private fun FsEventStore.hasCanonical(id: String): Boolean { private fun FsEventStore.hasCanonical(id: String): Boolean {
val p = val p =
@@ -24,6 +24,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.exists import kotlin.io.path.exists
@@ -65,194 +69,206 @@ class FsMaintenanceTest {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `lock file is created on open`() { fun `lock file is created on open`() =
assertTrue(root.resolve(".lock").exists()) runBlocking {
} assertTrue(root.resolve(".lock").exists())
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Transactions // Transactions
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `transaction commits all inserts on success`() { fun `transaction commits all inserts on success`() =
val a = note("a", 1) runBlocking {
val b = note("b", 2) val a = note("a", 1)
val c = note("c", 3) val b = note("b", 2)
val c = note("c", 3)
store.transaction {
insert(a)
insert(b)
insert(c)
}
val got = store.query<TextNoteEvent>(Filter(authors = listOf(signer.pubKey)))
assertEquals(setOf(a.id, b.id, c.id), got.map { it.id }.toSet())
}
@Test
fun `transaction propagates exceptions and stops processing`() {
val a = note("a", 1)
val b = note("b", 2)
val c = note("c", 3)
assertFailsWith<IllegalStateException> {
store.transaction { store.transaction {
insert(a) insert(a)
insert(b) insert(b)
throw IllegalStateException("boom")
// unreachable
@Suppress("UNREACHABLE_CODE")
insert(c) insert(c)
} }
}
// Events written before the throw are kept (per the plan: atomic- val got = store.query<TextNoteEvent>(Filter(authors = listOf(signer.pubKey)))
// per-event, serialised across writers — not all-or-nothing). assertEquals(setOf(a.id, b.id, c.id), got.map { it.id }.toSet())
assertTrue(store.count(Filter(ids = listOf(a.id))) == 1) }
assertTrue(store.count(Filter(ids = listOf(b.id))) == 1)
assertTrue(store.count(Filter(ids = listOf(c.id))) == 0)
}
@Test @Test
fun `transaction is re-entrant on the same thread`() { fun `transaction propagates exceptions and stops processing`() =
val a = note("a", 1) runBlocking {
// If flock were non-reentrant we'd self-deadlock here because val a = note("a", 1)
// insert() acquires the same lock the transaction already holds. val b = note("b", 2)
store.transaction { val c = note("c", 3)
insert(a)
// Call an outer-locking method from within the transaction. assertFailsWith<IllegalStateException> {
store.deleteExpiredEvents() store.transaction {
insert(a)
insert(b)
throw IllegalStateException("boom")
// unreachable
@Suppress("UNREACHABLE_CODE")
insert(c)
}
}
// Events written before the throw are kept (per the plan: atomic-
// per-event, serialised across writers — not all-or-nothing).
assertTrue(store.count(Filter(ids = listOf(a.id))) == 1)
assertTrue(store.count(Filter(ids = listOf(b.id))) == 1)
assertTrue(store.count(Filter(ids = listOf(c.id))) == 0)
}
@Test
fun `transaction is re-entrant on the same thread`() =
runBlocking {
val a = note("a", 1)
val b = note("b", 2)
// If flock were non-reentrant we'd self-deadlock here because
// insert() acquires the same lock the transaction already holds.
store.transaction {
insert(a)
insert(b)
}
// And a follow-up suspend call also re-enters the lock cleanly.
store.deleteExpiredEvents()
assertEquals(1, store.count(Filter(ids = listOf(a.id))))
assertEquals(1, store.count(Filter(ids = listOf(b.id))))
} }
assertEquals(1, store.count(Filter(ids = listOf(a.id))))
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// scrub — rebuild idx/ from canonical // scrub — rebuild idx/ from canonical
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `scrub rebuilds idx entries after a manual wipe`() { fun `scrub rebuilds idx entries after a manual wipe`() =
val a = note("hello bitcoin", 10) runBlocking {
val b = note("nostr stuff", 20) val a = note("hello bitcoin", 10)
store.insert(a) val b = note("nostr stuff", 20)
store.insert(b) store.insert(a)
store.insert(b)
// Blow away the entire idx/ tree behind the store's back. // Blow away the entire idx/ tree behind the store's back.
Files.walk(root.resolve("idx")).use { Files.walk(root.resolve("idx")).use {
it.sorted(Comparator.reverseOrder()).forEach { p -> Files.deleteIfExists(p) } it.sorted(Comparator.reverseOrder()).forEach { p -> Files.deleteIfExists(p) }
}
// Without scrub, index-driven queries find nothing.
assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(authors = listOf(signer.pubKey))).map { it.id })
store.scrub()
val got = store.query<TextNoteEvent>(Filter(authors = listOf(signer.pubKey))).map { it.id }.toSet()
assertEquals(setOf(a.id, b.id), got)
// FTS recovered too.
assertEquals(listOf(a.id), store.query<TextNoteEvent>(Filter(search = "bitcoin")).map { it.id })
} }
// Without scrub, index-driven queries find nothing.
assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(authors = listOf(signer.pubKey))).map { it.id })
store.scrub()
val got = store.query<TextNoteEvent>(Filter(authors = listOf(signer.pubKey))).map { it.id }.toSet()
assertEquals(setOf(a.id, b.id), got)
// FTS recovered too.
assertEquals(listOf(a.id), store.query<TextNoteEvent>(Filter(search = "bitcoin")).map { it.id })
}
@Test @Test
fun `scrub leaves replaceable slot intact`() { fun `scrub leaves replaceable slot intact`() =
// Replaceable slots pin events via hardlink even without the runBlocking {
// canonical. Scrub must not wipe slots. // Replaceable slots pin events via hardlink even without the
val meta = // canonical. Scrub must not wipe slots.
signer.sign<com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent>( val meta =
createdAt = 10, signer.sign<com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent>(
kind = 0, createdAt = 10,
tags = emptyArray(), kind = 0,
content = "{}", tags = emptyArray(),
) content = "{}",
store.insert(meta) )
val slot = root.resolve("replaceable/0/${signer.pubKey}.json") store.insert(meta)
assertTrue(slot.exists()) val slot = root.resolve("replaceable/0/${signer.pubKey}.json")
assertTrue(slot.exists())
store.scrub() store.scrub()
assertTrue(slot.exists(), "replaceable slot must survive scrub") assertTrue(slot.exists(), "replaceable slot must survive scrub")
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// compact — drop dangling idx entries // compact — drop dangling idx entries
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `compact drops idx entries whose canonical is gone`() { fun `compact drops idx entries whose canonical is gone`() =
val a = note("x", 10) runBlocking {
store.insert(a) val a = note("x", 10)
store.insert(a)
// Externally delete the canonical without touching idx/. // Externally delete the canonical without touching idx/.
val canonical = root.resolve("events/${a.id.substring(0, 2)}/${a.id.substring(2, 4)}/${a.id}.json") val canonical = root.resolve("events/${a.id.substring(0, 2)}/${a.id.substring(2, 4)}/${a.id}.json")
assertTrue(Files.deleteIfExists(canonical)) assertTrue(Files.deleteIfExists(canonical))
val kindDir = root.resolve("idx/kind/1") val kindDir = root.resolve("idx/kind/1")
assertEquals(1, kindDir.listDirectoryEntries().size, "dangling entry still present pre-compact") assertEquals(1, kindDir.listDirectoryEntries().size, "dangling entry still present pre-compact")
store.compact() store.compact()
assertEquals(0, kindDir.listDirectoryEntries().size, "dangling entry dropped post-compact") assertEquals(0, kindDir.listDirectoryEntries().size, "dangling entry dropped post-compact")
} }
@Test @Test
fun `compact leaves valid entries alone`() { fun `compact leaves valid entries alone`() =
val a = note("x", 10) runBlocking {
store.insert(a) val a = note("x", 10)
store.insert(a)
store.compact() store.compact()
val kindDir = root.resolve("idx/kind/1") val kindDir = root.resolve("idx/kind/1")
assertEquals(1, kindDir.listDirectoryEntries().size, "valid entry should not be touched") assertEquals(1, kindDir.listDirectoryEntries().size, "valid entry should not be touched")
assertEquals(listOf(a.id), store.query<TextNoteEvent>(Filter(ids = listOf(a.id))).map { it.id }) assertEquals(listOf(a.id), store.query<TextNoteEvent>(Filter(ids = listOf(a.id))).map { it.id })
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// close // close
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `close is idempotent`() { fun `close is idempotent`() =
store.close() runBlocking {
store.close() store.close()
} store.close()
}
@Test @Test
fun `reopen after close works`() { fun `reopen after close works`() =
val a = note("a", 1) runBlocking {
store.insert(a) val a = note("a", 1)
store.close() store.insert(a)
store.close()
val reopened = FsEventStore(root) val reopened = FsEventStore(root)
try { try {
assertEquals(listOf(a.id), reopened.query<TextNoteEvent>(Filter(ids = listOf(a.id))).map { it.id }) assertEquals(listOf(a.id), reopened.query<TextNoteEvent>(Filter(ids = listOf(a.id))).map { it.id })
} finally { } finally {
reopened.close() reopened.close()
}
} }
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Concurrency — two writer threads serialise cleanly // Concurrency — two writer threads serialise cleanly
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `concurrent inserts on two threads are both persisted`() { fun `concurrent inserts on two threads are both persisted`() =
val events = (1..20).map { note("n$it", it.toLong()) } runBlocking {
val half = events.size / 2 val events = (1..20).map { note("n$it", it.toLong()) }
val half = events.size / 2
val t1 = // Two real threads via Dispatchers.IO so the in-process lock has to
Thread { // arbitrate. join via coroutineScope.
events.take(half).forEach { store.insert(it) } coroutineScope {
launch(Dispatchers.IO) {
events.take(half).forEach { store.insert(it) }
}
launch(Dispatchers.IO) {
events.drop(half).forEach { store.insert(it) }
}
} }
val t2 =
Thread {
events.drop(half).forEach { store.insert(it) }
}
t1.start()
t2.start()
t1.join()
t2.join()
val got = store.query<TextNoteEvent>(Filter(authors = listOf(signer.pubKey))).map { it.id }.toSet() val got = store.query<TextNoteEvent>(Filter(authors = listOf(signer.pubKey))).map { it.id }.toSet()
assertEquals(events.map { it.id }.toSet(), got) assertEquals(events.map { it.id }.toSet(), got)
} }
} }
@@ -29,6 +29,7 @@ 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
import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.exists import kotlin.io.path.exists
@@ -81,7 +82,7 @@ class FsParityTest {
} }
/** Insert into both stores. Swallow SQLite rejections (we only care about the resulting state). */ /** Insert into both stores. Swallow SQLite rejections (we only care about the resulting state). */
private fun insertBoth(event: Event) { private suspend fun insertBoth(event: Event) {
try { try {
sqlite.insert(event) sqlite.insert(event)
} catch (_: Throwable) { } catch (_: Throwable) {
@@ -93,7 +94,7 @@ class FsParityTest {
} }
/** Assert both stores return the same ids (as a set) for the given filter. */ /** Assert both stores return the same ids (as a set) for the given filter. */
private fun assertParity( private suspend fun assertParity(
filter: Filter, filter: Filter,
message: String = "", message: String = "",
) { ) {
@@ -103,7 +104,7 @@ class FsParityTest {
} }
/** Same, but expect a stable DESC-by-createdAt ordering. */ /** Same, but expect a stable DESC-by-createdAt ordering. */
private fun assertParityOrdered( private suspend fun assertParityOrdered(
filter: Filter, filter: Filter,
message: String = "", message: String = "",
) { ) {
@@ -135,350 +136,368 @@ class FsParityTest {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `id lookup matches`() { fun `id lookup matches`() =
val n = note("hello", 10) runBlocking {
insertBoth(n) val n = note("hello", 10)
assertParity(Filter(ids = listOf(n.id))) insertBoth(n)
} assertParity(Filter(ids = listOf(n.id)))
}
@Test @Test
fun `kind + author query matches`() { fun `kind + author query matches`() =
val a = note("a", 1) runBlocking {
val b = note("b", 2) val a = note("a", 1)
val c = note("c", 3, s = otherSigner) val b = note("b", 2)
listOf(a, b, c).forEach(::insertBoth) val c = note("c", 3, s = otherSigner)
listOf(a, b, c).forEach { insertBoth(it) }
assertParityOrdered(Filter(kinds = listOf(1), authors = listOf(signer.pubKey))) assertParityOrdered(Filter(kinds = listOf(1), authors = listOf(signer.pubKey)))
assertParityOrdered(Filter(authors = listOf(signer.pubKey, otherSigner.pubKey))) assertParityOrdered(Filter(authors = listOf(signer.pubKey, otherSigner.pubKey)))
} }
@Test @Test
fun `since until limit match`() { fun `since until limit match`() =
repeat(10) { i -> insertBoth(note("n$i", i.toLong() + 1)) } runBlocking {
assertParityOrdered(Filter(authors = listOf(signer.pubKey), since = 4, until = 8)) repeat(10) { i -> insertBoth(note("n$i", i.toLong() + 1)) }
assertParityOrdered(Filter(authors = listOf(signer.pubKey), limit = 3)) assertParityOrdered(Filter(authors = listOf(signer.pubKey), since = 4, until = 8))
} assertParityOrdered(Filter(authors = listOf(signer.pubKey), limit = 3))
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Tag indexing // Tag indexing
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `single-letter tag queries match`() { fun `single-letter tag queries match`() =
val tagged = runBlocking {
signer.sign<Event>( val tagged =
createdAt = 5, signer.sign<Event>(
kind = 1, createdAt = 5,
tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "bitcoin")), kind = 1,
content = "x", tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "bitcoin")),
) content = "x",
val plain = note("plain", 6) )
insertBoth(tagged) val plain = note("plain", 6)
insertBoth(plain) insertBoth(tagged)
insertBoth(plain)
assertParity(Filter(tags = mapOf("t" to listOf("nostr")))) assertParity(Filter(tags = mapOf("t" to listOf("nostr"))))
assertParity(Filter(tags = mapOf("t" to listOf("nostr", "bitcoin")))) assertParity(Filter(tags = mapOf("t" to listOf("nostr", "bitcoin"))))
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Replaceable / Addressable // Replaceable / Addressable
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `replaceable newer wins parity`() { fun `replaceable newer wins parity`() =
val v1 = runBlocking {
signer.sign<Event>( val v1 =
createdAt = 100, signer.sign<Event>(
kind = 0, createdAt = 100,
tags = emptyArray(), kind = 0,
content = "{\"name\":\"v1\"}", tags = emptyArray(),
) content = "{\"name\":\"v1\"}",
val v2 = )
signer.sign<Event>( val v2 =
createdAt = 200, signer.sign<Event>(
kind = 0, createdAt = 200,
tags = emptyArray(), kind = 0,
content = "{\"name\":\"v2\"}", tags = emptyArray(),
) content = "{\"name\":\"v2\"}",
insertBoth(v1) )
insertBoth(v2) insertBoth(v1)
insertBoth(v2)
assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0))) assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0)))
assertParity(Filter(ids = listOf(v1.id))) assertParity(Filter(ids = listOf(v1.id)))
} }
@Test @Test
fun `replaceable older rejected parity`() { fun `replaceable older rejected parity`() =
val newer = runBlocking {
signer.sign<Event>(createdAt = 200, kind = 0, tags = emptyArray(), content = "{\"name\":\"new\"}") val newer =
val older = signer.sign<Event>(createdAt = 200, kind = 0, tags = emptyArray(), content = "{\"name\":\"new\"}")
signer.sign<Event>(createdAt = 100, kind = 0, tags = emptyArray(), content = "{\"name\":\"old\"}") val older =
insertBoth(newer) signer.sign<Event>(createdAt = 100, kind = 0, tags = emptyArray(), content = "{\"name\":\"old\"}")
insertBoth(older) insertBoth(newer)
insertBoth(older)
assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0))) assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0)))
} }
@Test @Test
fun `addressable d-tag dedup parity`() { fun `addressable d-tag dedup parity`() =
val v1 = article("intro", "v1", 10) runBlocking {
val v2 = article("intro", "v2", 20) val v1 = article("intro", "v1", 10)
val v3 = article("about", "bio", 15) val v2 = article("intro", "v2", 20)
insertBoth(v1) val v3 = article("about", "bio", 15)
insertBoth(v2) insertBoth(v1)
insertBoth(v3) insertBoth(v2)
insertBoth(v3)
assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND)))
} }
@Test @Test
fun `replaceable same-createdAt lexical id tiebreaker parity`() { fun `replaceable same-createdAt lexical id tiebreaker parity`() =
// Two kind-0 events with identical createdAt produce different ids runBlocking {
// because their content differs. NIP-01 says the lexically smaller // Two kind-0 events with identical createdAt produce different ids
// id wins on a tie. Both stores must agree, regardless of insertion // because their content differs. NIP-01 says the lexically smaller
// order. // id wins on a tie. Both stores must agree, regardless of insertion
val a = // order.
signer.sign<Event>( val a =
createdAt = 100, signer.sign<Event>(
kind = 0, createdAt = 100,
tags = emptyArray(), kind = 0,
content = "{\"name\":\"a\"}", tags = emptyArray(),
content = "{\"name\":\"a\"}",
)
val b =
signer.sign<Event>(
createdAt = 100,
kind = 0,
tags = emptyArray(),
content = "{\"name\":\"b\"}",
)
insertBoth(a)
insertBoth(b)
assertParity(
Filter(authors = listOf(signer.pubKey), kinds = listOf(0)),
"loser-then-winner: lexically smaller id should win",
) )
val b = assertParity(
signer.sign<Event>( Filter(ids = listOf(a.id, b.id)),
createdAt = 100, "the loser must not survive in the by-id query",
kind = 0,
tags = emptyArray(),
content = "{\"name\":\"b\"}",
) )
}
insertBoth(a)
insertBoth(b)
assertParity(
Filter(authors = listOf(signer.pubKey), kinds = listOf(0)),
"loser-then-winner: lexically smaller id should win",
)
assertParity(
Filter(ids = listOf(a.id, b.id)),
"the loser must not survive in the by-id query",
)
}
@Test @Test
fun `addressable same-createdAt lexical id tiebreaker parity`() { fun `addressable same-createdAt lexical id tiebreaker parity`() =
val a = article("tie", "version a", 100) runBlocking {
val b = article("tie", "version b", 100) val a = article("tie", "version a", 100)
val b = article("tie", "version b", 100)
insertBoth(a) insertBoth(a)
insertBoth(b) insertBoth(b)
assertParity( assertParity(
Filter( Filter(
authors = listOf(signer.pubKey), authors = listOf(signer.pubKey),
kinds = listOf(LongTextNoteEvent.KIND), kinds = listOf(LongTextNoteEvent.KIND),
tags = mapOf("d" to listOf("tie")), tags = mapOf("d" to listOf("tie")),
), ),
) )
assertParity(Filter(ids = listOf(a.id, b.id))) assertParity(Filter(ids = listOf(a.id, b.id)))
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Deletion (NIP-09) // Deletion (NIP-09)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `deletion by id parity`() { fun `deletion by id parity`() =
val a = note("a", 10) runBlocking {
val b = note("b", 20) val a = note("a", 10)
insertBoth(a) val b = note("b", 20)
insertBoth(b) insertBoth(a)
insertBoth(b)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(a), createdAt = 30)) val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(a), createdAt = 30))
insertBoth(del) insertBoth(del)
assertParity(Filter(ids = listOf(a.id))) assertParity(Filter(ids = listOf(a.id)))
assertParity(Filter(ids = listOf(b.id))) assertParity(Filter(ids = listOf(b.id)))
assertParity(Filter(kinds = listOf(DeletionEvent.KIND))) assertParity(Filter(kinds = listOf(DeletionEvent.KIND)))
// Re-insert blocked. // Re-insert blocked.
insertBoth(a) insertBoth(a)
assertParity(Filter(ids = listOf(a.id))) assertParity(Filter(ids = listOf(a.id)))
} }
@Test @Test
fun `deletion by address parity`() { fun `deletion by address parity`() =
val v = article("intro", "v1", 10) runBlocking {
insertBoth(v) val v = article("intro", "v1", 10)
insertBoth(v)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v), createdAt = 20)) val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v), createdAt = 20))
insertBoth(del) insertBoth(del)
assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND)))
// Older event at this address must be blocked, newer must pass. // Older event at this address must be blocked, newer must pass.
insertBoth(article("intro", "older", 5)) insertBoth(article("intro", "older", 5))
insertBoth(article("intro", "newer", 100)) insertBoth(article("intro", "newer", 100))
assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND)))
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Expiration (NIP-40) // Expiration (NIP-40)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `expiration sweep parity`() { fun `expiration sweep parity`() =
// Build events with future-then-past expirations relative to now. runBlocking {
val now = // Build events with future-then-past expirations relative to now.
com.vitorpamplona.quartz.utils.TimeUtils val now =
.now() com.vitorpamplona.quartz.utils.TimeUtils
val expired = .now()
signer.sign<Event>( val expired =
createdAt = now - 100, signer.sign<Event>(
kind = 1, createdAt = now - 100,
tags = arrayOf(arrayOf("expiration", (now - 50).toString())), kind = 1,
content = "old", tags = arrayOf(arrayOf("expiration", (now - 50).toString())),
) content = "old",
val alive = )
signer.sign<Event>( val alive =
createdAt = now - 100, signer.sign<Event>(
kind = 1, createdAt = now - 100,
tags = arrayOf(arrayOf("expiration", (now + 1_000_000).toString())), kind = 1,
content = "still here", tags = arrayOf(arrayOf("expiration", (now + 1_000_000).toString())),
) content = "still here",
insertBoth(expired) // both stores reject (already expired) )
insertBoth(alive) insertBoth(expired) // both stores reject (already expired)
insertBoth(alive)
assertParity(Filter(ids = listOf(expired.id))) assertParity(Filter(ids = listOf(expired.id)))
assertParity(Filter(ids = listOf(alive.id))) assertParity(Filter(ids = listOf(alive.id)))
// Sweep both; alive survives. // Sweep both; alive survives.
sqlite.deleteExpiredEvents() sqlite.deleteExpiredEvents()
fs.deleteExpiredEvents() fs.deleteExpiredEvents()
assertParity(Filter(ids = listOf(alive.id))) assertParity(Filter(ids = listOf(alive.id)))
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Search (NIP-50) // Search (NIP-50)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `search parity`() { fun `search parity`() =
val a = note("hello bitcoin", 1) runBlocking {
val b = note("nostr only", 2) val a = note("hello bitcoin", 1)
val c = note("bitcoin and nostr", 3) val b = note("nostr only", 2)
insertBoth(a) val c = note("bitcoin and nostr", 3)
insertBoth(b) insertBoth(a)
insertBoth(c) insertBoth(b)
insertBoth(c)
// Tokenizers differ slightly between SQLite FTS5 unicode61 and // Tokenizers differ slightly between SQLite FTS5 unicode61 and
// our Kotlin port, so we stick to plain ASCII single-token queries // our Kotlin port, so we stick to plain ASCII single-token queries
// where both should agree. // where both should agree.
assertParity(Filter(search = "bitcoin")) assertParity(Filter(search = "bitcoin"))
assertParity(Filter(search = "nostr")) assertParity(Filter(search = "nostr"))
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Count // Count
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `count parity across mixed stream`() { fun `count parity across mixed stream`() =
listOf( runBlocking {
note("a", 1), listOf(
note("b", 2), note("a", 1),
note("c", 3), note("b", 2),
note("from-other", 4, s = otherSigner), note("c", 3),
).forEach(::insertBoth) note("from-other", 4, s = otherSigner),
).forEach { insertBoth(it) }
val filter = Filter(authors = listOf(signer.pubKey)) val filter = Filter(authors = listOf(signer.pubKey))
assertEquals(sqlite.count(filter), fs.count(filter)) assertEquals(sqlite.count(filter), fs.count(filter))
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Mixed kitchen-sink scenario // Mixed kitchen-sink scenario
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `kitchen sink scenario`() { fun `kitchen sink scenario`() =
// Notes runBlocking {
val n1 = note("first", 1) // Notes
val n2 = note("second", 2) val n1 = note("first", 1)
// Replaceable val n2 = note("second", 2)
val meta1 = // Replaceable
signer.sign<Event>(createdAt = 10, kind = 0, tags = emptyArray(), content = "{\"name\":\"v1\"}") val meta1 =
val meta2 = signer.sign<Event>(createdAt = 10, kind = 0, tags = emptyArray(), content = "{\"name\":\"v1\"}")
signer.sign<Event>(createdAt = 20, kind = 0, tags = emptyArray(), content = "{\"name\":\"v2\"}") val meta2 =
// Addressable signer.sign<Event>(createdAt = 20, kind = 0, tags = emptyArray(), content = "{\"name\":\"v2\"}")
val artA = article("a", "A v1", 30) // Addressable
val artB = article("b", "B v1", 30) val artA = article("a", "A v1", 30)
val artBv2 = article("b", "B v2", 50) val artB = article("b", "B v1", 30)
// Deletion of n1 val artBv2 = article("b", "B v2", 50)
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n1), createdAt = 40)) // Deletion of n1
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n1), createdAt = 40))
listOf(n1, n2, meta1, meta2, artA, artB, artBv2, del).forEach(::insertBoth) listOf(n1, n2, meta1, meta2, artA, artB, artBv2, del).forEach { insertBoth(it) }
// Snapshots that should match. // Snapshots that should match.
assertParity(Filter(ids = listOf(n1.id)), "n1 deleted") assertParity(Filter(ids = listOf(n1.id)), "n1 deleted")
assertParity(Filter(ids = listOf(n2.id)), "n2 alive") assertParity(Filter(ids = listOf(n2.id)), "n2 alive")
assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0)), "metadata winner") assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(0)), "metadata winner")
assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND)), "articles set") assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND)), "articles set")
assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(DeletionEvent.KIND)), "deletion present") assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(DeletionEvent.KIND)), "deletion present")
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Multi-filter union // Multi-filter union
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `multi-filter union parity`() { fun `multi-filter union parity`() =
val a = note("a", 1) runBlocking {
val b = note("b", 2, s = otherSigner) val a = note("a", 1)
insertBoth(a) val b = note("b", 2, s = otherSigner)
insertBoth(b) insertBoth(a)
insertBoth(b)
val filters = val filters =
listOf( listOf(
Filter(authors = listOf(signer.pubKey)), Filter(authors = listOf(signer.pubKey)),
Filter(authors = listOf(otherSigner.pubKey)), Filter(authors = listOf(otherSigner.pubKey)),
)
assertEquals(
sqlite.query<Event>(filters).map { it.id }.toSet(),
fs.query<Event>(filters).map { it.id }.toSet(),
) )
}
assertEquals(
sqlite.query<Event>(filters).map { it.id }.toSet(),
fs.query<Event>(filters).map { it.id }.toSet(),
)
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Direct delete by filter // Direct delete by filter
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `delete by filter parity`() { fun `delete by filter parity`() =
val toKill = note("dead", 5) runBlocking {
val survivor = note("alive", 6) val toKill = note("dead", 5)
insertBoth(toKill) val survivor = note("alive", 6)
insertBoth(survivor) insertBoth(toKill)
insertBoth(survivor)
sqlite.delete(Filter(ids = listOf(toKill.id))) sqlite.delete(Filter(ids = listOf(toKill.id)))
fs.delete(Filter(ids = listOf(toKill.id))) fs.delete(Filter(ids = listOf(toKill.id)))
assertParity(Filter(authors = listOf(signer.pubKey))) assertParity(Filter(authors = listOf(signer.pubKey)))
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Helper: ensure SQLite store really does what we think // Helper: ensure SQLite store really does what we think
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `helper sanity - empty stores agree`() { fun `helper sanity - empty stores agree`() =
assertParity(Filter(authors = listOf(signer.pubKey))) runBlocking {
assertParity(Filter(kinds = listOf(1))) assertParity(Filter(authors = listOf(signer.pubKey)))
} assertParity(Filter(kinds = listOf(1)))
}
@Suppress("unused") @Suppress("unused")
private fun debugDump(label: String): String { private suspend fun debugDump(label: String): String {
val sqIds = val sqIds =
sqlite sqlite
.query<Event>(Filter(authors = listOf(signer.pubKey, otherSigner.pubKey))) .query<Event>(Filter(authors = listOf(signer.pubKey, otherSigner.pubKey)))
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.exists import kotlin.io.path.exists
@@ -71,351 +72,372 @@ class FsQueryTest {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `results ordered by created_at DESC`() { fun `results ordered by created_at DESC`() =
val a = signA("a", 1) runBlocking {
val b = signA("b", 3) val a = signA("a", 1)
val c = signA("c", 2) val b = signA("b", 3)
listOf(a, b, c).forEach(store::insert) val c = signA("c", 2)
listOf(a, b, c).forEach { store.insert(it) }
val got = store.query<TextNoteEvent>(Filter(authors = listOf(signerA.pubKey))) val got = store.query<TextNoteEvent>(Filter(authors = listOf(signerA.pubKey)))
assertEquals(listOf(b.id, c.id, a.id), got.map { it.id }) assertEquals(listOf(b.id, c.id, a.id), got.map { it.id })
} }
@Test @Test
fun `limit caps the result count`() { fun `limit caps the result count`() =
repeat(5) { i -> store.insert(signA("n$i", i.toLong() + 1)) } runBlocking {
val got = store.query<TextNoteEvent>(Filter(authors = listOf(signerA.pubKey), limit = 2)) repeat(5) { i -> store.insert(signA("n$i", i.toLong() + 1)) }
assertEquals(2, got.size) val got = store.query<TextNoteEvent>(Filter(authors = listOf(signerA.pubKey), limit = 2))
// Highest timestamps come first. assertEquals(2, got.size)
assertEquals("n4", got[0].content) // Highest timestamps come first.
assertEquals("n3", got[1].content) assertEquals("n4", got[0].content)
} assertEquals("n3", got[1].content)
}
@Test @Test
fun `limit of zero returns empty`() { fun `limit of zero returns empty`() =
store.insert(signA("x", 1)) runBlocking {
assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(authors = listOf(signerA.pubKey), limit = 0))) store.insert(signA("x", 1))
} assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(authors = listOf(signerA.pubKey), limit = 0)))
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Author / kind drivers // Author / kind drivers
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `author filter isolates one user`() { fun `author filter isolates one user`() =
val a = signA("from-a", 1) runBlocking {
val b = signB("from-b", 2) val a = signA("from-a", 1)
store.insert(a) val b = signB("from-b", 2)
store.insert(b) store.insert(a)
store.insert(b)
val got = store.query<TextNoteEvent>(Filter(authors = listOf(signerA.pubKey))) val got = store.query<TextNoteEvent>(Filter(authors = listOf(signerA.pubKey)))
assertEquals(listOf(a.id), got.map { it.id }) assertEquals(listOf(a.id), got.map { it.id })
} }
@Test @Test
fun `author filter with multiple authors unions them`() { fun `author filter with multiple authors unions them`() =
val a = signA("a", 1) runBlocking {
val b = signB("b", 2) val a = signA("a", 1)
store.insert(a) val b = signB("b", 2)
store.insert(b) store.insert(a)
store.insert(b)
val got = store.query<TextNoteEvent>(Filter(authors = listOf(signerA.pubKey, signerB.pubKey))) val got = store.query<TextNoteEvent>(Filter(authors = listOf(signerA.pubKey, signerB.pubKey)))
assertEquals(setOf(a.id, b.id), got.map { it.id }.toSet()) assertEquals(setOf(a.id, b.id), got.map { it.id }.toSet())
} }
@Test @Test
fun `kind filter returns only the requested kinds`() { fun `kind filter returns only the requested kinds`() =
// Build two events of different kinds. runBlocking {
val note = signA("note", 1) // Build two events of different kinds.
val ephemeralKinds = signerA.sign<Event>(createdAt = 2, kind = 30023, tags = arrayOf(arrayOf("d", "slug")), content = "article") val note = signA("note", 1)
store.insert(note) val ephemeralKinds = signerA.sign<Event>(createdAt = 2, kind = 30023, tags = arrayOf(arrayOf("d", "slug")), content = "article")
store.insert(ephemeralKinds) store.insert(note)
store.insert(ephemeralKinds)
val onlyNotes = store.query<Event>(Filter(kinds = listOf(1))) val onlyNotes = store.query<Event>(Filter(kinds = listOf(1)))
assertEquals(listOf(note.id), onlyNotes.map { it.id }) assertEquals(listOf(note.id), onlyNotes.map { it.id })
val onlyArticles = store.query<Event>(Filter(kinds = listOf(30023))) val onlyArticles = store.query<Event>(Filter(kinds = listOf(30023)))
assertEquals(listOf(ephemeralKinds.id), onlyArticles.map { it.id }) assertEquals(listOf(ephemeralKinds.id), onlyArticles.map { it.id })
} }
@Test @Test
fun `kind + author intersect via post-filter`() { fun `kind + author intersect via post-filter`() =
val a = signA("a", 1) runBlocking {
val b = signB("b", 2) val a = signA("a", 1)
store.insert(a) val b = signB("b", 2)
store.insert(b) store.insert(a)
store.insert(b)
val got = store.query<TextNoteEvent>(Filter(kinds = listOf(1), authors = listOf(signerA.pubKey))) val got = store.query<TextNoteEvent>(Filter(kinds = listOf(1), authors = listOf(signerA.pubKey)))
assertEquals(listOf(a.id), got.map { it.id }) assertEquals(listOf(a.id), got.map { it.id })
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Tag driver // Tag driver
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `tag filter matches single-letter tags`() { fun `tag filter matches single-letter tags`() =
val tagged = runBlocking {
signerA.sign<Event>( val tagged =
createdAt = 10, signerA.sign<Event>(
kind = 1, createdAt = 10,
tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "bitcoin")), kind = 1,
content = "tagged", tags = arrayOf(arrayOf("t", "nostr"), arrayOf("t", "bitcoin")),
) content = "tagged",
val untagged = signA("plain", 5) )
store.insert(tagged) val untagged = signA("plain", 5)
store.insert(untagged) store.insert(tagged)
store.insert(untagged)
val got = store.query<Event>(Filter(tags = mapOf("t" to listOf("nostr")))) val got = store.query<Event>(Filter(tags = mapOf("t" to listOf("nostr"))))
assertEquals(listOf(tagged.id), got.map { it.id }) assertEquals(listOf(tagged.id), got.map { it.id })
} }
@Test @Test
fun `tag OR within key returns union`() { fun `tag OR within key returns union`() =
val t1 = signerA.sign<Event>(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "n") runBlocking {
val t2 = signerA.sign<Event>(createdAt = 2, kind = 1, tags = arrayOf(arrayOf("t", "bitcoin")), content = "b") val t1 = signerA.sign<Event>(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "n")
val t3 = signerA.sign<Event>(createdAt = 3, kind = 1, tags = arrayOf(arrayOf("t", "other")), content = "o") val t2 = signerA.sign<Event>(createdAt = 2, kind = 1, tags = arrayOf(arrayOf("t", "bitcoin")), content = "b")
listOf(t1, t2, t3).forEach(store::insert) val t3 = signerA.sign<Event>(createdAt = 3, kind = 1, tags = arrayOf(arrayOf("t", "other")), content = "o")
listOf(t1, t2, t3).forEach { store.insert(it) }
val got = store.query<Event>(Filter(tags = mapOf("t" to listOf("nostr", "bitcoin")))) val got = store.query<Event>(Filter(tags = mapOf("t" to listOf("nostr", "bitcoin"))))
assertEquals(setOf(t1.id, t2.id), got.map { it.id }.toSet()) assertEquals(setOf(t1.id, t2.id), got.map { it.id }.toSet())
} }
@Test @Test
fun `tagsAll across keys requires all matches`() { fun `tagsAll across keys requires all matches`() =
val both = runBlocking {
signerA.sign<Event>( val both =
createdAt = 1, signerA.sign<Event>(
kind = 1, createdAt = 1,
tags = arrayOf(arrayOf("t", "nostr"), arrayOf("e", "a".repeat(64))), kind = 1,
content = "both", tags = arrayOf(arrayOf("t", "nostr"), arrayOf("e", "a".repeat(64))),
) content = "both",
val onlyT = signerA.sign<Event>(createdAt = 2, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "t-only") )
val onlyE = signerA.sign<Event>(createdAt = 3, kind = 1, tags = arrayOf(arrayOf("e", "a".repeat(64))), content = "e-only") val onlyT = signerA.sign<Event>(createdAt = 2, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "t-only")
listOf(both, onlyT, onlyE).forEach(store::insert) val onlyE = signerA.sign<Event>(createdAt = 3, kind = 1, tags = arrayOf(arrayOf("e", "a".repeat(64))), content = "e-only")
listOf(both, onlyT, onlyE).forEach { store.insert(it) }
val got = val got =
store.query<Event>( store.query<Event>(
Filter( Filter(
tagsAll = mapOf("t" to listOf("nostr"), "e" to listOf("a".repeat(64))), tagsAll = mapOf("t" to listOf("nostr"), "e" to listOf("a".repeat(64))),
), ),
) )
assertEquals(listOf(both.id), got.map { it.id }) assertEquals(listOf(both.id), got.map { it.id })
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Tag-value directory naming: raw when fs-safe, _h_<hash> otherwise. // Tag-value directory naming: raw when fs-safe, _h_<hash> otherwise.
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `safe ASCII tag values get raw directory names`() { fun `safe ASCII tag values get raw directory names`() =
val e = runBlocking {
signerA.sign<Event>( val e =
createdAt = 10, signerA.sign<Event>(
kind = 1, createdAt = 10,
tags = arrayOf(arrayOf("t", "nostr")), kind = 1,
content = "x", tags = arrayOf(arrayOf("t", "nostr")),
) content = "x",
store.insert(e) )
// The raw value is the directory name — directly inspectable. store.insert(e)
val rawDir = root.resolve("idx/tag/t/nostr") // The raw value is the directory name — directly inspectable.
assertTrue(rawDir.exists(), "ASCII-safe tag should land in idx/tag/t/nostr/") val rawDir = root.resolve("idx/tag/t/nostr")
assertEquals(1, rawDir.listDirectoryEntries().size) assertTrue(rawDir.exists(), "ASCII-safe tag should land in idx/tag/t/nostr/")
} assertEquals(1, rawDir.listDirectoryEntries().size)
}
@Test @Test
fun `pubkey p-tag uses raw 64-hex directory name`() { fun `pubkey p-tag uses raw 64-hex directory name`() =
// The motivating case: notifications. p-tags pointing at a runBlocking {
// pubkey land under idx/tag/p/<pubkey>/ — no hash, directly // The motivating case: notifications. p-tags pointing at a
// ls-able. // pubkey land under idx/tag/p/<pubkey>/ — no hash, directly
val target = signerB.pubKey // ls-able.
val e = val target = signerB.pubKey
signerA.sign<Event>( val e =
createdAt = 5, signerA.sign<Event>(
kind = 1, createdAt = 5,
tags = arrayOf(arrayOf("p", target)), kind = 1,
content = "@you", tags = arrayOf(arrayOf("p", target)),
) content = "@you",
store.insert(e) )
val pDir = root.resolve("idx/tag/p/$target") store.insert(e)
assertTrue(pDir.exists(), "p-tag pubkey should be ls-able directly: idx/tag/p/$target/") val pDir = root.resolve("idx/tag/p/$target")
assertEquals(1, pDir.listDirectoryEntries().size) assertTrue(pDir.exists(), "p-tag pubkey should be ls-able directly: idx/tag/p/$target/")
} assertEquals(1, pDir.listDirectoryEntries().size)
}
@Test @Test
fun `tag value with emoji falls back to hashed directory name`() { fun `tag value with emoji falls back to hashed directory name`() =
val e = runBlocking {
signerA.sign<Event>( val e =
createdAt = 10, signerA.sign<Event>(
kind = 1, createdAt = 10,
tags = arrayOf(arrayOf("t", "🔥")), kind = 1,
content = "x", tags = arrayOf(arrayOf("t", "🔥")),
) content = "x",
store.insert(e) )
// Exactly one entry under t/ and it must be in the _h_ hash store.insert(e)
// bucket — emoji is not fs-safe. // Exactly one entry under t/ and it must be in the _h_ hash
val tDir = root.resolve("idx/tag/t") // bucket — emoji is not fs-safe.
val entries = tDir.listDirectoryEntries().map { it.fileName.toString() } val tDir = root.resolve("idx/tag/t")
assertEquals(1, entries.size, "expected one bucket dir, got: $entries") val entries = tDir.listDirectoryEntries().map { it.fileName.toString() }
assertTrue(entries.single().startsWith("_h_"), "emoji tag must hash; got '${entries.single()}'") assertEquals(1, entries.size, "expected one bucket dir, got: $entries")
} assertTrue(entries.single().startsWith("_h_"), "emoji tag must hash; got '${entries.single()}'")
}
@Test @Test
fun `tag value containing a slash falls back to hashed directory name`() { fun `tag value containing a slash falls back to hashed directory name`() =
val e = runBlocking {
signerA.sign<Event>( val e =
createdAt = 10, signerA.sign<Event>(
kind = 1, createdAt = 10,
tags = arrayOf(arrayOf("r", "https://example.com/page")), kind = 1,
content = "x", tags = arrayOf(arrayOf("r", "https://example.com/page")),
) content = "x",
store.insert(e) )
val rDir = root.resolve("idx/tag/r") store.insert(e)
val entries = rDir.listDirectoryEntries().map { it.fileName.toString() } val rDir = root.resolve("idx/tag/r")
assertEquals(1, entries.size, "expected one bucket dir, got: $entries") val entries = rDir.listDirectoryEntries().map { it.fileName.toString() }
assertTrue(entries.single().startsWith("_h_"), "URL tag must hash; got '${entries.single()}'") assertEquals(1, entries.size, "expected one bucket dir, got: $entries")
} assertTrue(entries.single().startsWith("_h_"), "URL tag must hash; got '${entries.single()}'")
}
@Test @Test
fun `query round-trips for both raw and hashed values`() { fun `query round-trips for both raw and hashed values`() =
// Each query must use the same naming rule as the writer or it runBlocking {
// walks a directory that doesn't exist. Insert both a raw-safe // Each query must use the same naming rule as the writer or it
// and a hash-required tag and verify they're both findable. // walks a directory that doesn't exist. Insert both a raw-safe
val safe = // and a hash-required tag and verify they're both findable.
signerA.sign<Event>( val safe =
createdAt = 1, signerA.sign<Event>(
kind = 1, createdAt = 1,
tags = arrayOf(arrayOf("t", "nostr")), kind = 1,
content = "safe", tags = arrayOf(arrayOf("t", "nostr")),
content = "safe",
)
val unsafe =
signerA.sign<Event>(
createdAt = 2,
kind = 1,
tags = arrayOf(arrayOf("t", "🔥")),
content = "unsafe",
)
store.insert(safe)
store.insert(unsafe)
assertEquals(
listOf(safe.id),
store.query<Event>(Filter(tags = mapOf("t" to listOf("nostr")))).map { it.id },
) )
val unsafe = assertEquals(
signerA.sign<Event>( listOf(unsafe.id),
createdAt = 2, store.query<Event>(Filter(tags = mapOf("t" to listOf("🔥")))).map { it.id },
kind = 1,
tags = arrayOf(arrayOf("t", "🔥")),
content = "unsafe",
) )
store.insert(safe) }
store.insert(unsafe)
assertEquals(
listOf(safe.id),
store.query<Event>(Filter(tags = mapOf("t" to listOf("nostr")))).map { it.id },
)
assertEquals(
listOf(unsafe.id),
store.query<Event>(Filter(tags = mapOf("t" to listOf("🔥")))).map { it.id },
)
}
@Test @Test
fun `non-single-letter tags are not reverse-indexed`() { fun `non-single-letter tags are not reverse-indexed`() =
// SQLite parity: DefaultIndexingStrategy only indexes single-letter runBlocking {
// tag names, so a tag-driven query for `mytag = foo` finds no // SQLite parity: DefaultIndexingStrategy only indexes single-letter
// candidates. The event is still persisted and can be fetched via // tag names, so a tag-driven query for `mytag = foo` finds no
// id / author / kind — just not via a reverse tag lookup. // candidates. The event is still persisted and can be fetched via
val e = // id / author / kind — just not via a reverse tag lookup.
signerA.sign<Event>( val e =
createdAt = 1, signerA.sign<Event>(
kind = 1, createdAt = 1,
tags = arrayOf(arrayOf("mytag", "foo")), kind = 1,
content = "x", tags = arrayOf(arrayOf("mytag", "foo")),
) content = "x",
store.insert(e) )
store.insert(e)
assertEquals(emptyList(), store.query<Event>(Filter(tags = mapOf("mytag" to listOf("foo")))).map { it.id }) assertEquals(emptyList(), store.query<Event>(Filter(tags = mapOf("mytag" to listOf("foo")))).map { it.id })
assertEquals(listOf(e.id), store.query<Event>(Filter(authors = listOf(signerA.pubKey))).map { it.id }) assertEquals(listOf(e.id), store.query<Event>(Filter(authors = listOf(signerA.pubKey))).map { it.id })
assertEquals(listOf(e.id), store.query<Event>(Filter(ids = listOf(e.id))).map { it.id }) assertEquals(listOf(e.id), store.query<Event>(Filter(ids = listOf(e.id))).map { it.id })
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// since / until // since / until
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `since and until window filter`() { fun `since and until window filter`() =
val e1 = signA("t1", 100) runBlocking {
val e2 = signA("t2", 200) val e1 = signA("t1", 100)
val e3 = signA("t3", 300) val e2 = signA("t2", 200)
listOf(e1, e2, e3).forEach(store::insert) val e3 = signA("t3", 300)
listOf(e1, e2, e3).forEach { store.insert(it) }
val got = store.query<TextNoteEvent>(Filter(since = 150, until = 250)) val got = store.query<TextNoteEvent>(Filter(since = 150, until = 250))
assertEquals(listOf(e2.id), got.map { it.id }) assertEquals(listOf(e2.id), got.map { it.id })
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// count // count
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `count matches query size`() { fun `count matches query size`() =
repeat(4) { i -> store.insert(signA("n$i", i.toLong() + 1)) } runBlocking {
val filter = Filter(authors = listOf(signerA.pubKey)) repeat(4) { i -> store.insert(signA("n$i", i.toLong() + 1)) }
assertEquals(store.query<TextNoteEvent>(filter).size, store.count(filter)) val filter = Filter(authors = listOf(signerA.pubKey))
} assertEquals(store.query<TextNoteEvent>(filter).size, store.count(filter))
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Index hardlink maintenance // Index hardlink maintenance
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `insert creates hardlinks in every expected index dir`() { fun `insert creates hardlinks in every expected index dir`() =
val tagged = runBlocking {
signerA.sign<Event>( val tagged =
createdAt = 42, signerA.sign<Event>(
kind = 1, createdAt = 42,
tags = arrayOf(arrayOf("t", "nostr")), kind = 1,
content = "x", tags = arrayOf(arrayOf("t", "nostr")),
) content = "x",
store.insert(tagged) )
store.insert(tagged)
val kindDir = root.resolve("idx/kind/1") val kindDir = root.resolve("idx/kind/1")
val authorDir = root.resolve("idx/author/${signerA.pubKey}") val authorDir = root.resolve("idx/author/${signerA.pubKey}")
assertTrue(kindDir.exists() && kindDir.listDirectoryEntries().size == 1, "kind index missing") assertTrue(kindDir.exists() && kindDir.listDirectoryEntries().size == 1, "kind index missing")
assertTrue(authorDir.exists() && authorDir.listDirectoryEntries().size == 1, "author index missing") assertTrue(authorDir.exists() && authorDir.listDirectoryEntries().size == 1, "author index missing")
val tagNameDir = root.resolve("idx/tag/t") val tagNameDir = root.resolve("idx/tag/t")
assertTrue(tagNameDir.exists(), "tag 't' dir missing") assertTrue(tagNameDir.exists(), "tag 't' dir missing")
val tagValueDirs = tagNameDir.listDirectoryEntries() val tagValueDirs = tagNameDir.listDirectoryEntries()
assertEquals(1, tagValueDirs.size, "exactly one tag-value subdir expected") assertEquals(1, tagValueDirs.size, "exactly one tag-value subdir expected")
assertEquals(1, tagValueDirs[0].listDirectoryEntries().size, "tag-value dir should contain one entry") assertEquals(1, tagValueDirs[0].listDirectoryEntries().size, "tag-value dir should contain one entry")
} }
@Test @Test
fun `delete removes hardlinks so directories become empty`() { fun `delete removes hardlinks so directories become empty`() =
val e = signerA.sign<Event>(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "x") runBlocking {
store.insert(e) val e = signerA.sign<Event>(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "x")
store.delete(e.id) store.insert(e)
store.delete(e.id)
val kindDir = root.resolve("idx/kind/1") val kindDir = root.resolve("idx/kind/1")
val authorDir = root.resolve("idx/author/${signerA.pubKey}") val authorDir = root.resolve("idx/author/${signerA.pubKey}")
val tagValueDirs = val tagValueDirs =
root root
.resolve("idx/tag/t") .resolve("idx/tag/t")
.takeIf { it.exists() } .takeIf { it.exists() }
?.listDirectoryEntries() ?.listDirectoryEntries()
.orEmpty() .orEmpty()
// Directories may remain as empty husks — what matters is the entries are gone. // Directories may remain as empty husks — what matters is the entries are gone.
if (kindDir.exists()) assertEquals(0, kindDir.listDirectoryEntries().size, "kind entry leaked") if (kindDir.exists()) assertEquals(0, kindDir.listDirectoryEntries().size, "kind entry leaked")
if (authorDir.exists()) assertEquals(0, authorDir.listDirectoryEntries().size, "author entry leaked") if (authorDir.exists()) assertEquals(0, authorDir.listDirectoryEntries().size, "author entry leaked")
tagValueDirs.forEach { assertEquals(0, it.listDirectoryEntries().size, "tag entry leaked") } tagValueDirs.forEach { assertEquals(0, it.listDirectoryEntries().size, "tag entry leaked") }
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Seed persistence across reopen // Seed persistence across reopen
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `reopening the store preserves queryability`() { fun `reopening the store preserves queryability`() =
val tagged = signerA.sign<Event>(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "x") runBlocking {
store.insert(tagged) val tagged = signerA.sign<Event>(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "x")
store.close() store.insert(tagged)
store.close()
val reopened = FsEventStore(root) val reopened = FsEventStore(root)
try { try {
val got = reopened.query<Event>(Filter(tags = mapOf("t" to listOf("nostr")))) val got = reopened.query<Event>(Filter(tags = mapOf("t" to listOf("nostr"))))
assertEquals(listOf(tagged.id), got.map { it.id }) assertEquals(listOf(tagged.id), got.map { it.id })
} finally { } finally {
reopened.close() reopened.close()
}
} }
}
} }
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.exists import kotlin.io.path.exists
@@ -66,193 +67,209 @@ class FsSearchTest {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `tokenizer splits on whitespace and punctuation`() { fun `tokenizer splits on whitespace and punctuation`() =
assertEquals(setOf("hello", "world"), FsSearchTokenizer.tokenize("hello, world!")) runBlocking {
} assertEquals(setOf("hello", "world"), FsSearchTokenizer.tokenize("hello, world!"))
}
@Test @Test
fun `tokenizer is case insensitive`() { fun `tokenizer is case insensitive`() =
assertEquals(FsSearchTokenizer.tokenize("Bitcoin"), FsSearchTokenizer.tokenize("BITCOIN")) runBlocking {
assertEquals(FsSearchTokenizer.tokenize("Bitcoin"), FsSearchTokenizer.tokenize("bitcoin")) assertEquals(FsSearchTokenizer.tokenize("Bitcoin"), FsSearchTokenizer.tokenize("BITCOIN"))
} assertEquals(FsSearchTokenizer.tokenize("Bitcoin"), FsSearchTokenizer.tokenize("bitcoin"))
}
@Test @Test
fun `tokenizer handles empty and punctuation-only strings`() { fun `tokenizer handles empty and punctuation-only strings`() =
assertEquals(emptySet<String>(), FsSearchTokenizer.tokenize("")) runBlocking {
assertEquals(emptySet<String>(), FsSearchTokenizer.tokenize("...")) assertEquals(emptySet<String>(), FsSearchTokenizer.tokenize(""))
assertEquals(emptySet<String>(), FsSearchTokenizer.tokenize(" ")) assertEquals(emptySet<String>(), FsSearchTokenizer.tokenize("..."))
} assertEquals(emptySet<String>(), FsSearchTokenizer.tokenize(" "))
}
@Test @Test
fun `tokenizer keeps unicode letters`() { fun `tokenizer keeps unicode letters`() =
assertEquals(setOf("café", "über"), FsSearchTokenizer.tokenize("Café Über")) runBlocking {
} assertEquals(setOf("café", "über"), FsSearchTokenizer.tokenize("Café Über"))
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Index maintenance // Index maintenance
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `searchable event creates one fts entry per unique token`() { fun `searchable event creates one fts entry per unique token`() =
val n = note("bitcoin nostr bitcoin", ts = 100) runBlocking {
store.insert(n) val n = note("bitcoin nostr bitcoin", ts = 100)
store.insert(n)
val ftsRoot = root.resolve("idx/fts") val ftsRoot = root.resolve("idx/fts")
val tokenDirs = ftsRoot.listDirectoryEntries().map { it.fileName.toString() }.toSet() val tokenDirs = ftsRoot.listDirectoryEntries().map { it.fileName.toString() }.toSet()
// TextNoteEvent.indexableContent() prepends a "Subject: " prefix so // TextNoteEvent.indexableContent() prepends a "Subject: " prefix so
// we get the content tokens plus the subject ones. What matters is // we get the content tokens plus the subject ones. What matters is
// that each unique token yields exactly one entry under its dir. // that each unique token yields exactly one entry under its dir.
assertTrue("bitcoin" in tokenDirs) assertTrue("bitcoin" in tokenDirs)
assertTrue("nostr" in tokenDirs) assertTrue("nostr" in tokenDirs)
assertEquals(1, ftsRoot.resolve("bitcoin").listDirectoryEntries().size) assertEquals(1, ftsRoot.resolve("bitcoin").listDirectoryEntries().size)
assertEquals(1, ftsRoot.resolve("nostr").listDirectoryEntries().size) assertEquals(1, ftsRoot.resolve("nostr").listDirectoryEntries().size)
} }
@Test @Test
fun `non-searchable event does not produce fts entries`() { fun `non-searchable event does not produce fts entries`() =
val meta = runBlocking {
signer.sign<MetadataEvent>( val meta =
createdAt = 1, signer.sign<MetadataEvent>(
kind = MetadataEvent.KIND, createdAt = 1,
tags = emptyArray(), kind = MetadataEvent.KIND,
content = "{\"name\":\"vitor\"}", tags = emptyArray(),
) content = "{\"name\":\"vitor\"}",
store.insert(meta) )
store.insert(meta)
val ftsRoot = root.resolve("idx/fts")
assertEquals(0, ftsRoot.listDirectoryEntries().size, "MetadataEvent is not SearchableEvent") val ftsRoot = root.resolve("idx/fts")
} assertEquals(0, ftsRoot.listDirectoryEntries().size, "MetadataEvent is not SearchableEvent")
}
@Test
fun `delete removes fts entries`() { @Test
val n = note("bitcoin nostr", ts = 100) fun `delete removes fts entries`() =
store.insert(n) runBlocking {
store.delete(n.id) val n = note("bitcoin nostr", ts = 100)
store.insert(n)
val ftsRoot = root.resolve("idx/fts") store.delete(n.id)
// Token directories may remain as empty husks.
for (tokenDir in ftsRoot.listDirectoryEntries()) { val ftsRoot = root.resolve("idx/fts")
assertEquals(0, tokenDir.listDirectoryEntries().size, "token entry leaked: $tokenDir") // Token directories may remain as empty husks.
for (tokenDir in ftsRoot.listDirectoryEntries()) {
assertEquals(0, tokenDir.listDirectoryEntries().size, "token entry leaked: $tokenDir")
}
} }
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Search query semantics // Search query semantics
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `single-token search returns the matching event`() { fun `single-token search returns the matching event`() =
val a = note("bitcoin is fun", ts = 1) runBlocking {
val b = note("nostr is also fun", ts = 2) val a = note("bitcoin is fun", ts = 1)
store.insert(a) val b = note("nostr is also fun", ts = 2)
store.insert(b) store.insert(a)
store.insert(b)
val got = store.query<TextNoteEvent>(Filter(search = "bitcoin")) val got = store.query<TextNoteEvent>(Filter(search = "bitcoin"))
assertEquals(listOf(a.id), got.map { it.id }) assertEquals(listOf(a.id), got.map { it.id })
} }
@Test @Test
fun `multi-token search is AND across tokens`() { fun `multi-token search is AND across tokens`() =
val a = note("bitcoin only", ts = 1) runBlocking {
val b = note("nostr only", ts = 2) val a = note("bitcoin only", ts = 1)
val c = note("bitcoin and nostr", ts = 3) val b = note("nostr only", ts = 2)
store.insert(a) val c = note("bitcoin and nostr", ts = 3)
store.insert(b) store.insert(a)
store.insert(c) store.insert(b)
store.insert(c)
val got = store.query<TextNoteEvent>(Filter(search = "bitcoin nostr"))
assertEquals(listOf(c.id), got.map { it.id }, "AND semantics: only the doc with both tokens matches") val got = store.query<TextNoteEvent>(Filter(search = "bitcoin nostr"))
} assertEquals(listOf(c.id), got.map { it.id }, "AND semantics: only the doc with both tokens matches")
}
@Test
fun `search results are ordered by createdAt DESC`() { @Test
val older = note("bitcoin first", ts = 10) fun `search results are ordered by createdAt DESC`() =
val newer = note("bitcoin again", ts = 20) runBlocking {
store.insert(older) val older = note("bitcoin first", ts = 10)
store.insert(newer) val newer = note("bitcoin again", ts = 20)
store.insert(older)
val got = store.query<TextNoteEvent>(Filter(search = "bitcoin")) store.insert(newer)
assertEquals(listOf(newer.id, older.id), got.map { it.id })
} val got = store.query<TextNoteEvent>(Filter(search = "bitcoin"))
assertEquals(listOf(newer.id, older.id), got.map { it.id })
@Test }
fun `search respects limit`() {
repeat(5) { i -> store.insert(note("bitcoin doc $i", ts = i.toLong() + 1)) } @Test
val got = store.query<TextNoteEvent>(Filter(search = "bitcoin", limit = 2)) fun `search respects limit`() =
assertEquals(2, got.size) runBlocking {
} repeat(5) { i -> store.insert(note("bitcoin doc $i", ts = i.toLong() + 1)) }
val got = store.query<TextNoteEvent>(Filter(search = "bitcoin", limit = 2))
@Test assertEquals(2, got.size)
fun `search composes with kinds and authors via post-filter`() { }
val match = note("bitcoin maximalism", ts = 5)
store.insert(match) @Test
fun `search composes with kinds and authors via post-filter`() =
val got = runBlocking {
store.query<TextNoteEvent>( val match = note("bitcoin maximalism", ts = 5)
Filter(search = "bitcoin", kinds = listOf(1), authors = listOf(signer.pubKey)), store.insert(match)
)
assertEquals(listOf(match.id), got.map { it.id }) val got =
store.query<TextNoteEvent>(
val miss = Filter(search = "bitcoin", kinds = listOf(1), authors = listOf(signer.pubKey)),
store.query<TextNoteEvent>( )
Filter(search = "bitcoin", kinds = listOf(2)), assertEquals(listOf(match.id), got.map { it.id })
)
assertEquals(emptyList(), miss.map { it.id }) val miss =
} store.query<TextNoteEvent>(
Filter(search = "bitcoin", kinds = listOf(2)),
@Test )
fun `search with no matching token returns empty`() { assertEquals(emptyList(), miss.map { it.id })
store.insert(note("nostr only", ts = 1)) }
assertEquals(
emptyList(), @Test
store.query<TextNoteEvent>(Filter(search = "bitcoin")).map { it.id }, fun `search with no matching token returns empty`() =
) runBlocking {
} store.insert(note("nostr only", ts = 1))
assertEquals(
@Test emptyList(),
fun `blank search string is ignored`() { store.query<TextNoteEvent>(Filter(search = "bitcoin")).map { it.id },
val a = note("anything", ts = 1) )
store.insert(a) }
// Blank search shouldn't drive by FTS — the planner falls through
// to all-kinds, and the event surfaces. @Test
val got = store.query<TextNoteEvent>(Filter(search = " ")) fun `blank search string is ignored`() =
assertEquals(listOf(a.id), got.map { it.id }) runBlocking {
} val a = note("anything", ts = 1)
store.insert(a)
@Test // Blank search shouldn't drive by FTS — the planner falls through
fun `search survives reopen`() { // to all-kinds, and the event surfaces.
val n = note("persistent token", ts = 100) val got = store.query<TextNoteEvent>(Filter(search = " "))
store.insert(n) assertEquals(listOf(a.id), got.map { it.id })
store.close() }
val reopened = FsEventStore(root) @Test
try { fun `search survives reopen`() =
val got = reopened.query<TextNoteEvent>(Filter(search = "persistent")) runBlocking {
assertEquals(listOf(n.id), got.map { it.id }) val n = note("persistent token", ts = 100)
} finally { store.insert(n)
reopened.close() store.close()
val reopened = FsEventStore(root)
try {
val got = reopened.query<TextNoteEvent>(Filter(search = "persistent"))
assertEquals(listOf(n.id), got.map { it.id })
} finally {
reopened.close()
}
} }
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Maintenance under replaceable / deletion / vanish // Maintenance under replaceable / deletion / vanish
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `fts entry is unlinked when event is deleted`() { fun `fts entry is unlinked when event is deleted`() =
val n = note("unique-token-zzz", ts = 1) runBlocking {
store.insert(n) val n = note("unique-token-zzz", ts = 1)
assertTrue(root.resolve("idx/fts/unique").exists()) store.insert(n)
assertTrue(root.resolve("idx/fts/token").exists()) assertTrue(root.resolve("idx/fts/unique").exists())
assertTrue(root.resolve("idx/fts/zzz").exists()) assertTrue(root.resolve("idx/fts/token").exists())
assertTrue(root.resolve("idx/fts/zzz").exists())
store.delete(n.id) store.delete(n.id)
assertFalse( assertFalse(
root.resolve("idx/fts/zzz").let { it.exists() && it.listDirectoryEntries().isNotEmpty() }, root.resolve("idx/fts/zzz").let { it.exists() && it.listDirectoryEntries().isNotEmpty() },
"zzz token entry should be unlinked", "zzz token entry should be unlinked",
) )
// And a search no longer finds it. // And a search no longer finds it.
assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(search = "zzz")).map { it.id }) assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(search = "zzz")).map { it.id })
} }
} }
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.exists import kotlin.io.path.exists
@@ -72,171 +73,181 @@ class FsSlotsTest {
) )
@Test @Test
fun `newer replaceable evicts older`() { fun `newer replaceable evicts older`() =
val v1 = metadata("old", 100) runBlocking {
val v2 = metadata("new", 200) val v1 = metadata("old", 100)
store.insert(v1) val v2 = metadata("new", 200)
store.insert(v2) store.insert(v1)
store.insert(v2)
// Only the newer survives a query by author. // Only the newer survives a query by author.
val got = store.query<MetadataEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) val got = store.query<MetadataEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND)))
assertEquals(listOf(v2.id), got.map { it.id }) assertEquals(listOf(v2.id), got.map { it.id })
// The older canonical is gone. // The older canonical is gone.
assertFalse(store.hasCanonical(v1.id), "older canonical should be removed") assertFalse(store.hasCanonical(v1.id), "older canonical should be removed")
} }
@Test @Test
fun `older replaceable is rejected when newer exists`() { fun `older replaceable is rejected when newer exists`() =
val newer = metadata("new", 200) runBlocking {
val older = metadata("old", 100) val newer = metadata("new", 200)
store.insert(newer) val older = metadata("old", 100)
store.insert(older) store.insert(newer)
store.insert(older)
// Newer still wins. // Newer still wins.
val got = store.query<MetadataEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) val got = store.query<MetadataEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND)))
assertEquals(listOf(newer.id), got.map { it.id }) assertEquals(listOf(newer.id), got.map { it.id })
// And the older was never persisted. // And the older was never persisted.
assertFalse(store.hasCanonical(older.id), "older should have been rejected") assertFalse(store.hasCanonical(older.id), "older should have been rejected")
} }
@Test @Test
fun `equal timestamp replaceable resolves by lexical id (NIP-01)`() { fun `equal timestamp replaceable resolves by lexical id (NIP-01)`() =
val a = metadata("a", 100) runBlocking {
val b = metadata("b", 100) val a = metadata("a", 100)
// NIP-01 tiebreaker: when createdAt ties, the lexically smaller val b = metadata("b", 100)
// id wins, regardless of insertion order. // NIP-01 tiebreaker: when createdAt ties, the lexically smaller
val (winner, loser) = if (a.id < b.id) a to b else b to a // id wins, regardless of insertion order.
val (winner, loser) = if (a.id < b.id) a to b else b to a
// Loser inserted first, then winner — winner must replace. // Loser inserted first, then winner — winner must replace.
store.insert(loser) store.insert(loser)
store.insert(winner) store.insert(winner)
val got = store.query<MetadataEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) val got = store.query<MetadataEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND)))
assertEquals(1, got.size) assertEquals(1, got.size)
assertEquals(winner.id, got.single().id) assertEquals(winner.id, got.single().id)
} }
@Test @Test
fun `equal timestamp replaceable rejects higher id when winner already present`() { fun `equal timestamp replaceable rejects higher id when winner already present`() =
val a = metadata("a", 100) runBlocking {
val b = metadata("b", 100) val a = metadata("a", 100)
val (winner, loser) = if (a.id < b.id) a to b else b to a val b = metadata("b", 100)
val (winner, loser) = if (a.id < b.id) a to b else b to a
// Winner inserted first — loser must NOT take the slot. // Winner inserted first — loser must NOT take the slot.
store.insert(winner) store.insert(winner)
store.insert(loser) store.insert(loser)
val got = store.query<MetadataEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) val got = store.query<MetadataEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND)))
assertEquals(1, got.size) assertEquals(1, got.size)
assertEquals(winner.id, got.single().id) assertEquals(winner.id, got.single().id)
} }
@Test @Test
fun `replaceable slot file contains the current winner`() { fun `replaceable slot file contains the current winner`() =
val v = metadata("only", 100) runBlocking {
store.insert(v) val v = metadata("only", 100)
store.insert(v)
val slot = root.resolve("replaceable/${MetadataEvent.KIND}/${signer.pubKey}.json") val slot = root.resolve("replaceable/${MetadataEvent.KIND}/${signer.pubKey}.json")
assertTrue(slot.exists(), "slot must exist") assertTrue(slot.exists(), "slot must exist")
val parsed = Event.fromJson(slot.readText()) val parsed = Event.fromJson(slot.readText())
assertEquals(v.id, parsed.id) assertEquals(v.id, parsed.id)
} }
@Test @Test
fun `replaceable slot survives canonical deletion via hardlink`() { fun `replaceable slot survives canonical deletion via hardlink`() =
val v = metadata("x", 100) runBlocking {
store.insert(v) val v = metadata("x", 100)
store.insert(v)
// Simulate a user (or bug) removing the canonical file. // Simulate a user (or bug) removing the canonical file.
val canonical = val canonical =
root root
.resolve("events") .resolve("events")
.resolve(v.id.substring(0, 2)) .resolve(v.id.substring(0, 2))
.resolve(v.id.substring(2, 4)) .resolve(v.id.substring(2, 4))
.resolve("${v.id}.json") .resolve("${v.id}.json")
assertTrue(Files.deleteIfExists(canonical)) assertTrue(Files.deleteIfExists(canonical))
val slot = root.resolve("replaceable/${MetadataEvent.KIND}/${signer.pubKey}.json") val slot = root.resolve("replaceable/${MetadataEvent.KIND}/${signer.pubKey}.json")
assertTrue(slot.exists(), "slot should persist even if canonical is gone (hardlink to same inode)") assertTrue(slot.exists(), "slot should persist even if canonical is gone (hardlink to same inode)")
val parsed = Event.fromJson(slot.readText()) val parsed = Event.fromJson(slot.readText())
assertEquals(v.id, parsed.id) assertEquals(v.id, parsed.id)
} }
@Test @Test
fun `eviction unlinks index hardlinks for the old winner`() { fun `eviction unlinks index hardlinks for the old winner`() =
val v1 = metadata("old", 100) runBlocking {
val v2 = metadata("new", 200) val v1 = metadata("old", 100)
store.insert(v1) val v2 = metadata("new", 200)
store.insert(v2) store.insert(v1)
store.insert(v2)
// Author index should have exactly one entry — the winner. // Author index should have exactly one entry — the winner.
val authorDir = root.resolve("idx/author/${signer.pubKey}") val authorDir = root.resolve("idx/author/${signer.pubKey}")
val entries = val entries =
Files.list(authorDir).use { s -> Files.list(authorDir).use { s ->
s.toList().map { it.fileName.toString() } s.toList().map { it.fileName.toString() }
}
assertEquals(1, entries.size, "author index should only hold the winner")
assertTrue(entries.single().endsWith("-${v2.id}"), "author index entry must point at winner")
}
@Test
fun `slot shortcut serves replaceable queries even when idx is wiped`() {
// Belt-and-suspenders for the planner shortcut: a query pinned to
// (kinds=[0], authors=[pk]) must hit the slot directly without
// touching idx/. Wipe idx/ to prove the shortcut isn't relying on
// it.
val v = metadata("p", 100)
store.insert(v)
java.nio.file.Files
.walk(root.resolve("idx"))
.use { s ->
s.sorted(Comparator.reverseOrder()).forEach {
java.nio.file.Files
.deleteIfExists(it)
} }
} assertEquals(1, entries.size, "author index should only hold the winner")
assertTrue(entries.single().endsWith("-${v2.id}"), "author index entry must point at winner")
val got = store.query<MetadataEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND))) }
assertEquals(listOf(v.id), got.map { it.id }, "slot shortcut should serve from replaceable/, not idx/")
}
@Test @Test
fun `slot shortcut serves addressable queries when d-tag supplied`() { fun `slot shortcut serves replaceable queries even when idx is wiped`() =
val v = article("intro", "v", 10) runBlocking {
store.insert(v) // Belt-and-suspenders for the planner shortcut: a query pinned to
java.nio.file.Files // (kinds=[0], authors=[pk]) must hit the slot directly without
.walk(root.resolve("idx")) // touching idx/. Wipe idx/ to prove the shortcut isn't relying on
.use { s -> // it.
s.sorted(Comparator.reverseOrder()).forEach { val v = metadata("p", 100)
java.nio.file.Files store.insert(v)
.deleteIfExists(it) java.nio.file.Files
.walk(root.resolve("idx"))
.use { s ->
s.sorted(Comparator.reverseOrder()).forEach {
java.nio.file.Files
.deleteIfExists(it)
}
} }
}
val got = val got = store.query<MetadataEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND)))
store.query<LongTextNoteEvent>( assertEquals(listOf(v.id), got.map { it.id }, "slot shortcut should serve from replaceable/, not idx/")
Filter( }
authors = listOf(signer.pubKey),
kinds = listOf(LongTextNoteEvent.KIND),
tags = mapOf("d" to listOf("intro")),
),
)
assertEquals(listOf(v.id), got.map { it.id })
}
@Test @Test
fun `delete of current replaceable winner clears the slot`() { fun `slot shortcut serves addressable queries when d-tag supplied`() =
val v = metadata("only", 100) runBlocking {
store.insert(v) val v = article("intro", "v", 10)
store.insert(v)
java.nio.file.Files
.walk(root.resolve("idx"))
.use { s ->
s.sorted(Comparator.reverseOrder()).forEach {
java.nio.file.Files
.deleteIfExists(it)
}
}
val slot = root.resolve("replaceable/${MetadataEvent.KIND}/${signer.pubKey}.json") val got =
assertTrue(slot.exists()) store.query<LongTextNoteEvent>(
Filter(
authors = listOf(signer.pubKey),
kinds = listOf(LongTextNoteEvent.KIND),
tags = mapOf("d" to listOf("intro")),
),
)
assertEquals(listOf(v.id), got.map { it.id })
}
store.delete(v.id) @Test
assertFalse(slot.exists(), "slot should be cleared when winner is deleted") fun `delete of current replaceable winner clears the slot`() =
} runBlocking {
val v = metadata("only", 100)
store.insert(v)
val slot = root.resolve("replaceable/${MetadataEvent.KIND}/${signer.pubKey}.json")
assertTrue(slot.exists())
store.delete(v.id)
assertFalse(slot.exists(), "slot should be cleared when winner is deleted")
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Addressable (kinds 30000-39999) // Addressable (kinds 30000-39999)
@@ -255,100 +266,107 @@ class FsSlotsTest {
) )
@Test @Test
fun `newer addressable evicts older for same d-tag`() { fun `newer addressable evicts older for same d-tag`() =
val v1 = article("intro", "draft 1", 10) runBlocking {
val v2 = article("intro", "draft 2", 20) val v1 = article("intro", "draft 1", 10)
store.insert(v1) val v2 = article("intro", "draft 2", 20)
store.insert(v2) store.insert(v1)
store.insert(v2)
val got = store.query<LongTextNoteEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) val got = store.query<LongTextNoteEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND)))
assertEquals(listOf(v2.id), got.map { it.id }) assertEquals(listOf(v2.id), got.map { it.id })
assertFalse(store.hasCanonical(v1.id), "older draft canonical should be removed") assertFalse(store.hasCanonical(v1.id), "older draft canonical should be removed")
} }
@Test @Test
fun `addressable with different d-tags coexist`() { fun `addressable with different d-tags coexist`() =
val intro = article("intro", "hello", 10) runBlocking {
val about = article("about", "bio", 15) val intro = article("intro", "hello", 10)
store.insert(intro) val about = article("about", "bio", 15)
store.insert(about) store.insert(intro)
store.insert(about)
val got = store.query<LongTextNoteEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND))) val got = store.query<LongTextNoteEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND)))
assertEquals(setOf(intro.id, about.id), got.map { it.id }.toSet()) assertEquals(setOf(intro.id, about.id), got.map { it.id }.toSet())
} }
@Test @Test
fun `older addressable is rejected when newer exists`() { fun `older addressable is rejected when newer exists`() =
val newer = article("slug", "new", 200) runBlocking {
val older = article("slug", "old", 100) val newer = article("slug", "new", 200)
store.insert(newer) val older = article("slug", "old", 100)
store.insert(older) store.insert(newer)
store.insert(older)
val got = store.query<LongTextNoteEvent>(Filter(authors = listOf(signer.pubKey))) val got = store.query<LongTextNoteEvent>(Filter(authors = listOf(signer.pubKey)))
assertEquals(listOf(newer.id), got.map { it.id }) assertEquals(listOf(newer.id), got.map { it.id })
} }
@Test @Test
fun `addressable slot file contains the current winner`() { fun `addressable slot file contains the current winner`() =
val v = article("intro", "hello", 10) runBlocking {
store.insert(v) val v = article("intro", "hello", 10)
store.insert(v)
val dHash = FsLayout.sha256Hex("intro") val dHash = FsLayout.sha256Hex("intro")
val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json")
assertTrue(slot.exists()) assertTrue(slot.exists())
val parsed = Event.fromJson(slot.readText()) val parsed = Event.fromJson(slot.readText())
assertEquals(v.id, parsed.id) assertEquals(v.id, parsed.id)
} }
@Test @Test
fun `empty d-tag gets its own slot`() { fun `empty d-tag gets its own slot`() =
val v = article("", "homepage", 1) runBlocking {
store.insert(v) val v = article("", "homepage", 1)
store.insert(v)
val dHash = FsLayout.sha256Hex("") val dHash = FsLayout.sha256Hex("")
val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json")
assertTrue(slot.exists()) assertTrue(slot.exists())
} }
@Test @Test
fun `delete of current addressable winner clears the slot`() { fun `delete of current addressable winner clears the slot`() =
val v = article("intro", "hello", 10) runBlocking {
store.insert(v) val v = article("intro", "hello", 10)
val dHash = FsLayout.sha256Hex("intro") store.insert(v)
val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json") val dHash = FsLayout.sha256Hex("intro")
assertTrue(slot.exists()) val slot = root.resolve("addressable/${LongTextNoteEvent.KIND}/${signer.pubKey}/$dHash.json")
assertTrue(slot.exists())
store.delete(v.id) store.delete(v.id)
assertFalse(slot.exists()) assertFalse(slot.exists())
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Non-replaceable events: no slot involvement // Non-replaceable events: no slot involvement
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `regular text note has no slot`() { fun `regular text note has no slot`() =
val note = runBlocking {
signer.sign<Event>( val note =
createdAt = 1, signer.sign<Event>(
kind = 1, createdAt = 1,
tags = emptyArray(), kind = 1,
content = "plain", tags = emptyArray(),
) content = "plain",
store.insert(note) )
store.insert(note)
// No entries under replaceable/ or addressable/ — only the scaffolded dirs exist. // No entries under replaceable/ or addressable/ — only the scaffolded dirs exist.
val replaceableDir = root.resolve("replaceable") val replaceableDir = root.resolve("replaceable")
val addressableDir = root.resolve("addressable") val addressableDir = root.resolve("addressable")
assertEquals( assertEquals(
0, 0,
Files.walk(replaceableDir).use { s -> s.filter { Files.isRegularFile(it) }.count() }, Files.walk(replaceableDir).use { s -> s.filter { Files.isRegularFile(it) }.count() },
) )
assertEquals( assertEquals(
0, 0,
Files.walk(addressableDir).use { s -> s.filter { Files.isRegularFile(it) }.count() }, Files.walk(addressableDir).use { s -> s.filter { Files.isRegularFile(it) }.count() },
) )
} }
// helper — check canonical existence // helper — check canonical existence
private fun FsEventStore.hasCanonical(id: String): Boolean { private fun FsEventStore.hasCanonical(id: String): Boolean {
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.runBlocking
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import kotlin.io.path.exists import kotlin.io.path.exists
@@ -81,171 +82,182 @@ class FsVanishTest {
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `vanish for this relay cascades older events from the same author`() { fun `vanish for this relay cascades older events from the same author`() =
val n1 = note("a", 10) runBlocking {
val n2 = note("b", 20) val n1 = note("a", 10)
val n3 = note("c", 30) // same ts as vanish — survives because cascade uses strict < val n2 = note("b", 20)
store.insert(n1) val n3 = note("c", 30) // same ts as vanish — survives because cascade uses strict <
store.insert(n2) store.insert(n1)
store.insert(n3) store.insert(n2)
store.insert(n3)
val v = vanish(ts = 30) val v = vanish(ts = 30)
store.insert(v) store.insert(v)
assertFalse(store.hasCanonical(n1.id), "n1 should be cascade-deleted") assertFalse(store.hasCanonical(n1.id), "n1 should be cascade-deleted")
assertFalse(store.hasCanonical(n2.id), "n2 should be cascade-deleted") assertFalse(store.hasCanonical(n2.id), "n2 should be cascade-deleted")
assertTrue(store.hasCanonical(n3.id), "n3 (createdAt == vanish.createdAt) survives") assertTrue(store.hasCanonical(n3.id), "n3 (createdAt == vanish.createdAt) survives")
assertTrue(store.hasCanonical(v.id)) assertTrue(store.hasCanonical(v.id))
}
@Test
fun `vanish for a different relay does NOT cascade`() {
val n = note("x", 10)
store.insert(n)
val v = vanish(ts = 20, relayUrl = "wss://elsewhere.example")
store.insert(v)
assertTrue(store.hasCanonical(n.id), "vanish scoped to another relay must not cascade")
// The kind-62 event itself is still persisted (it's just a normal event).
assertTrue(store.hasCanonical(v.id))
// No vanish tombstone installed.
val tombDir = root.resolve("tombstones/vanish")
if (tombDir.exists()) {
assertEquals(0, Files.list(tombDir).use { it.toList() }.size)
} }
}
@Test @Test
fun `vanishFromEverywhere always cascades regardless of relay`() { fun `vanish for a different relay does NOT cascade`() =
val n = note("x", 10) runBlocking {
store.insert(n) val n = note("x", 10)
store.insert(n)
val v = vanishEverywhere(ts = 20) val v = vanish(ts = 20, relayUrl = "wss://elsewhere.example")
store.insert(v) store.insert(v)
assertFalse(store.hasCanonical(n.id)) assertTrue(store.hasCanonical(n.id), "vanish scoped to another relay must not cascade")
} // The kind-62 event itself is still persisted (it's just a normal event).
assertTrue(store.hasCanonical(v.id))
// No vanish tombstone installed.
val tombDir = root.resolve("tombstones/vanish")
if (tombDir.exists()) {
assertEquals(0, Files.list(tombDir).use { it.toList() }.size)
}
}
@Test
fun `vanishFromEverywhere always cascades regardless of relay`() =
runBlocking {
val n = note("x", 10)
store.insert(n)
val v = vanishEverywhere(ts = 20)
store.insert(v)
assertFalse(store.hasCanonical(n.id))
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Block re-insert // Block re-insert
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `events older than vanish are blocked from re-insertion`() { fun `events older than vanish are blocked from re-insertion`() =
val n = note("a", 10) runBlocking {
store.insert(n) val n = note("a", 10)
store.insert(n)
val v = vanish(ts = 50) val v = vanish(ts = 50)
store.insert(v) store.insert(v)
// Re-insert blocked. // Re-insert blocked.
store.insert(n) store.insert(n)
assertFalse(store.hasCanonical(n.id)) assertFalse(store.hasCanonical(n.id))
// A brand-new older event by the same author also blocked. // A brand-new older event by the same author also blocked.
val older = note("older", 5) val older = note("older", 5)
store.insert(older) store.insert(older)
assertFalse(store.hasCanonical(older.id)) assertFalse(store.hasCanonical(older.id))
} }
@Test @Test
fun `events at vanish ts are blocked, parity with SQLite`() { fun `events at vanish ts are blocked, parity with SQLite`() =
val v = vanish(ts = 50) runBlocking {
store.insert(v) val v = vanish(ts = 50)
store.insert(v)
val equal = note("equal", 50) val equal = note("equal", 50)
store.insert(equal) store.insert(equal)
assertFalse(store.hasCanonical(equal.id), "createdAt == vanish.createdAt should be blocked") assertFalse(store.hasCanonical(equal.id), "createdAt == vanish.createdAt should be blocked")
} }
@Test @Test
fun `events newer than vanish still pass`() { fun `events newer than vanish still pass`() =
val v = vanish(ts = 50) runBlocking {
store.insert(v) val v = vanish(ts = 50)
store.insert(v)
val newer = note("newer", 100) val newer = note("newer", 100)
store.insert(newer) store.insert(newer)
assertTrue(store.hasCanonical(newer.id)) assertTrue(store.hasCanonical(newer.id))
} }
@Test @Test
fun `another author is unaffected by my vanish`() { fun `another author is unaffected by my vanish`() =
val mine = note("mine", 10) runBlocking {
store.insert(mine) val mine = note("mine", 10)
val theirs = note("theirs", 5, s = otherSigner) store.insert(mine)
store.insert(theirs) val theirs = note("theirs", 5, s = otherSigner)
store.insert(theirs)
val v = vanish(ts = 50) val v = vanish(ts = 50)
store.insert(v) store.insert(v)
assertFalse(store.hasCanonical(mine.id), "my old event cascade-deleted") assertFalse(store.hasCanonical(mine.id), "my old event cascade-deleted")
assertTrue(store.hasCanonical(theirs.id), "other author's event is unaffected") assertTrue(store.hasCanonical(theirs.id), "other author's event is unaffected")
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Multiple vanish requests — strongest cutoff wins // Multiple vanish requests — strongest cutoff wins
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `later vanish raises the cutoff`() { fun `later vanish raises the cutoff`() =
val n100 = note("at-100", 100) runBlocking {
store.insert(n100) val n100 = note("at-100", 100)
store.insert(vanish(ts = 50)) store.insert(n100)
// n100 still around because 100 > 50. store.insert(vanish(ts = 50))
assertTrue(store.hasCanonical(n100.id)) // n100 still around because 100 > 50.
assertTrue(store.hasCanonical(n100.id))
// Stronger vanish at ts=200 cascades it. // Stronger vanish at ts=200 cascades it.
store.insert(vanish(ts = 200)) store.insert(vanish(ts = 200))
assertFalse(store.hasCanonical(n100.id)) assertFalse(store.hasCanonical(n100.id))
// And new events at ts=150 are now blocked. // And new events at ts=150 are now blocked.
val mid = note("mid", 150) val mid = note("mid", 150)
store.insert(mid) store.insert(mid)
assertFalse(store.hasCanonical(mid.id)) assertFalse(store.hasCanonical(mid.id))
} }
@Test @Test
fun `earlier vanish does not lower a stronger cutoff`() { fun `earlier vanish does not lower a stronger cutoff`() =
store.insert(vanish(ts = 200)) runBlocking {
store.insert(vanish(ts = 50)) // older — should be a no-op for the tombstone store.insert(vanish(ts = 200))
store.insert(vanish(ts = 50)) // older — should be a no-op for the tombstone
val mid = note("mid", 150) val mid = note("mid", 150)
store.insert(mid) store.insert(mid)
assertFalse(store.hasCanonical(mid.id), "stronger cutoff stays at 200") assertFalse(store.hasCanonical(mid.id), "stronger cutoff stays at 200")
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Tombstone is a hardlink to the kind-62 event // Tombstone is a hardlink to the kind-62 event
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `vanish tombstone shares an inode with the kind-62 event`() { fun `vanish tombstone shares an inode with the kind-62 event`() =
val v = vanish(ts = 30) runBlocking {
store.insert(v) val v = vanish(ts = 30)
store.insert(v)
val tombDir = root.resolve("tombstones/vanish") val tombDir = root.resolve("tombstones/vanish")
val entries = Files.list(tombDir).use { it.toList() } val entries = Files.list(tombDir).use { it.toList() }
assertEquals(1, entries.size) assertEquals(1, entries.size)
val canonical = root.resolve("events/${v.id.substring(0, 2)}/${v.id.substring(2, 4)}/${v.id}.json") val canonical = root.resolve("events/${v.id.substring(0, 2)}/${v.id.substring(2, 4)}/${v.id}.json")
val tombKey = Files.readAttributes(entries.single(), java.nio.file.attribute.BasicFileAttributes::class.java).fileKey() val tombKey = Files.readAttributes(entries.single(), java.nio.file.attribute.BasicFileAttributes::class.java).fileKey()
val canKey = Files.readAttributes(canonical, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey() val canKey = Files.readAttributes(canonical, java.nio.file.attribute.BasicFileAttributes::class.java).fileKey()
assertEquals(canKey, tombKey, "vanish tombstone should be a hardlink to the kind-62 canonical") assertEquals(canKey, tombKey, "vanish tombstone should be a hardlink to the kind-62 canonical")
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Vanish event itself remains queryable // Vanish event itself remains queryable
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@Test @Test
fun `vanish event itself is indexed and queryable`() { fun `vanish event itself is indexed and queryable`() =
val v = vanish(ts = 30) runBlocking {
store.insert(v) val v = vanish(ts = 30)
store.insert(v)
val byKind = store.query<Event>(Filter(kinds = listOf(RequestToVanishEvent.KIND))) val byKind = store.query<Event>(Filter(kinds = listOf(RequestToVanishEvent.KIND)))
assertEquals(listOf(v.id), byKind.map { it.id }) assertEquals(listOf(v.id), byKind.map { it.id })
} }
private fun FsEventStore.hasCanonical(id: String): Boolean { private fun FsEventStore.hasCanonical(id: String): Boolean {
val p = val p =
@@ -0,0 +1,205 @@
/*
* 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.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.utils.Secp256k1Instance
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.deleteIfExists
import kotlin.io.path.exists
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Stress test for the SQLite connection pool. Pre-pool, two coroutines
* inserting at the same time would race on the shared `SQLiteConnection`
* (`androidx.sqlite` connections aren't thread-safe) and crash with
* either `SQLITE_ERROR: cannot start a transaction within a transaction`
* or a corrupted prepared statement (`SQLITE_MISUSE`).
*
* With [SQLiteConnectionPool] writes serialise behind a coroutine `Mutex`
* and reads run in parallel against a fixed pool of reader connections,
* matching what Room does. The test launches a fan-out of inserts and
* concurrent reads, then asserts every inserted event is visible and the
* count is exact.
*/
class ParallelInsertTest {
private val signer = NostrSignerSync()
private lateinit var dbFile: Path
private lateinit var store: EventStore
@BeforeTest
fun setup() {
Secp256k1Instance
// Use a real file so the pool can hand out independent reader
// connections — :memory: would make every connection a separate DB.
dbFile = Files.createTempFile("parallel-insert-", ".db")
// Driver expects to open the file itself; ensure the placeholder
// is gone so SQLite can create a fresh DB.
Files.deleteIfExists(dbFile)
store = EventStore(dbName = dbFile.toAbsolutePath().toString(), relay = null)
}
@AfterTest
fun tearDown() {
store.close()
// SQLite leaves -wal / -shm sidecars next to the main file under WAL.
listOf("", "-wal", "-shm", "-journal").forEach { suffix ->
Path.of(dbFile.toString() + suffix).deleteIfExists()
}
}
@Test
fun `parallel inserts on N coroutines all succeed`() =
runBlocking {
val perCoroutine = 200
val coroutines = 8
val total = perCoroutine * coroutines
val events =
(0 until total).map { i ->
signer.sign(TextNoteEvent.build("p$i", createdAt = i.toLong() + 1))
}
// Fan out inserts across `coroutines` workers on the IO
// dispatcher (multi-thread). Without the pool's writer mutex
// these all race on a single SQLiteConnection and crash.
coroutineScope {
events.chunked(perCoroutine).forEach { chunk ->
launch(Dispatchers.IO) {
for (e in chunk) store.insert(e)
}
}
}
assertEquals(total, store.count(Filter()), "every insert must be visible")
val byId = store.query<TextNoteEvent>(Filter()).associateBy { it.id }
for (e in events) {
assertTrue(byId.containsKey(e.id), "missing event ${e.id.take(8)}")
}
}
@Test
fun `parallel reads run alongside writes without crashing`() =
runBlocking {
val writes = 500
val events =
(0 until writes).map { i ->
signer.sign(TextNoteEvent.build("rw$i", createdAt = i.toLong() + 1))
}
coroutineScope {
// Writer feed.
launch(Dispatchers.IO) {
for (e in events) store.insert(e)
}
// Multiple reader fans-out: count() and query() running
// continuously while inserts are still in flight. Asserts
// none of these crash with SQLITE_MISUSE.
val readers =
List(4) {
async(Dispatchers.IO) {
var lastSeen = 0
repeat(100) {
val n = store.count(Filter())
assertTrue(n in 0..writes)
if (n > lastSeen) lastSeen = n
}
lastSeen
}
}
readers.awaitAll()
}
assertEquals(writes, store.count(Filter()))
}
@Test
fun `parallel transaction batches all commit`() =
runBlocking {
val batches = 8
val perBatch = 50
val total = batches * perBatch
val events =
(0 until total).map { i ->
signer.sign(TextNoteEvent.build("t$i", createdAt = i.toLong() + 1))
}
// Each coroutine wraps its slice in store.transaction { ... },
// exercising the writer mutex around BEGIN/COMMIT pairs.
coroutineScope {
events.chunked(perBatch).forEach { chunk ->
launch(Dispatchers.IO) {
store.transaction {
for (e in chunk) insert(e)
}
}
}
}
assertEquals(total, store.count(Filter()))
}
@Test
fun `pool with file-backed db survives reopen`() =
runBlocking {
// Smoke test that the pool migration runs idempotently when
// a writer connection is reopened against an existing DB.
val first = signer.sign(TextNoteEvent.build("first", createdAt = 1))
store.insert(first)
store.close()
val reopened = EventStore(dbName = dbFile.toAbsolutePath().toString(), relay = null)
try {
assertTrue(dbFile.exists())
val got = reopened.query<TextNoteEvent>(Filter(ids = listOf(first.id)))
assertEquals(listOf(first.id), got.map { it.id })
// And then more parallel inserts still work on the
// reopened pool.
val moreCount = 20
val more = (0 until moreCount).map { signer.sign(TextNoteEvent.build("m$it", createdAt = it.toLong() + 100)) }
coroutineScope {
more.forEach { e ->
launch(Dispatchers.IO) { reopened.insert(e) }
}
}
assertEquals(1 + moreCount, reopened.count(Filter()))
} finally {
reopened.close()
}
}
}