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:
@@ -173,7 +173,7 @@ class Context(
|
||||
* publish from", which mirrors `User.outboxRelays()` in the
|
||||
* Android app.
|
||||
*/
|
||||
fun outboxRelays(): Set<NormalizedRelayUrl> =
|
||||
suspend fun outboxRelays(): Set<NormalizedRelayUrl> =
|
||||
relaysOf(identity.pubKeyHex)?.writeRelaysNorm()?.takeIf { it.isNotEmpty() }?.toSet()
|
||||
?: DefaultNIP65RelaySet
|
||||
|
||||
@@ -181,7 +181,7 @@ class Context(
|
||||
* DM inbox relays (NIP-17 kind:10050) for this account. Falls back
|
||||
* 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()
|
||||
?: DefaultDMRelayList.toSet()
|
||||
|
||||
@@ -190,12 +190,12 @@ class Context(
|
||||
* back to [outboxRelays] when no kind:10051 has been seen — same
|
||||
* 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()
|
||||
?: outboxRelays()
|
||||
|
||||
/** 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 —
|
||||
@@ -208,7 +208,7 @@ class Context(
|
||||
* most reliable place to find a stranger's replaceable events even when
|
||||
* we and they have completely disjoint relay configurations.
|
||||
*/
|
||||
fun bootstrapRelays(): Set<NormalizedRelayUrl> =
|
||||
suspend fun bootstrapRelays(): Set<NormalizedRelayUrl> =
|
||||
buildSet {
|
||||
addAll(anyRelays())
|
||||
addAll(DefaultNIP65RelaySet)
|
||||
@@ -319,7 +319,7 @@ class Context(
|
||||
* Every event-arrival path in the CLI funnels through this method
|
||||
* 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()) {
|
||||
System.err.println("[cli] dropped event ${event.id.take(8)} kind=${event.kind} — bad signature")
|
||||
return false
|
||||
@@ -342,7 +342,7 @@ class Context(
|
||||
* this user. Callers that need a network fetch on miss should fall
|
||||
* back to [drain] explicitly — this helper never hits the network.
|
||||
*/
|
||||
fun profileOf(pubKey: HexKey): MetadataEvent? =
|
||||
suspend fun profileOf(pubKey: HexKey): MetadataEvent? =
|
||||
store
|
||||
.query<Event>(
|
||||
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
|
||||
* [pubKey]. `null` when Amy has never seen one.
|
||||
*/
|
||||
fun relaysOf(pubKey: HexKey): AdvertisedRelayListEvent? =
|
||||
suspend fun relaysOf(pubKey: HexKey): AdvertisedRelayListEvent? =
|
||||
store
|
||||
.query<Event>(
|
||||
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
|
||||
* lookups without re-hitting relays.
|
||||
*/
|
||||
fun contactsOf(pubKey: HexKey): ContactListEvent? =
|
||||
suspend fun contactsOf(pubKey: HexKey): ContactListEvent? =
|
||||
store
|
||||
.query<Event>(
|
||||
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
|
||||
* `dm send` to resolve where to deliver a wrap.
|
||||
*/
|
||||
fun dmInboxOf(pubKey: HexKey): ChatMessageRelayListEvent? =
|
||||
suspend fun dmInboxOf(pubKey: HexKey): ChatMessageRelayListEvent? =
|
||||
store
|
||||
.query<Event>(
|
||||
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
|
||||
* locate where the recipient publishes their KeyPackages.
|
||||
*/
|
||||
fun keyPackageRelaysOf(pubKey: HexKey): KeyPackageRelayListEvent? =
|
||||
suspend fun keyPackageRelaysOf(pubKey: HexKey): KeyPackageRelayListEvent? =
|
||||
store
|
||||
.query<Event>(
|
||||
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
|
||||
* (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 kp = keyPackageRelaysOf(pubKey)
|
||||
val nip65 = relaysOf(pubKey)
|
||||
|
||||
@@ -142,7 +142,7 @@ object FeedCommand {
|
||||
* an arbitrary `--author` we have no idea where they publish, so we
|
||||
* widen to the bootstrap union.
|
||||
*/
|
||||
private fun relaysForReadingFeed(
|
||||
private suspend fun relaysForReadingFeed(
|
||||
ctx: Context,
|
||||
mode: String,
|
||||
): Set<NormalizedRelayUrl> =
|
||||
|
||||
@@ -226,7 +226,7 @@ object ProfileCommands {
|
||||
* 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.
|
||||
*/
|
||||
private fun relaysForReadingProfile(
|
||||
private suspend fun relaysForReadingProfile(
|
||||
ctx: Context,
|
||||
isSelf: Boolean,
|
||||
): 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)
|
||||
try {
|
||||
val self = ctx.identity.pubKeyHex
|
||||
|
||||
@@ -132,7 +132,7 @@ object StoreCommands {
|
||||
return 0
|
||||
}
|
||||
|
||||
private fun sweepExpired(dataDir: DataDir): Int =
|
||||
private suspend fun sweepExpired(dataDir: DataDir): Int =
|
||||
withStore(dataDir) { store ->
|
||||
val expiresAtDir = dataDir.eventsDir.toPath().resolve("idx/expires_at")
|
||||
val before = countEntries(expiresAtDir)
|
||||
|
||||
+2
-2
@@ -46,7 +46,7 @@ class LiveEventStore(
|
||||
onBufferOverflow = BufferOverflow.DROP_LATEST, // Default behavior
|
||||
)
|
||||
|
||||
fun insert(event: Event) {
|
||||
suspend fun insert(event: Event) {
|
||||
store.insert(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)
|
||||
}
|
||||
|
||||
+30
-32
@@ -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 ---------------------------------------------------------
|
||||
private fun handleAuth(cmd: AuthCmd) {
|
||||
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 {
|
||||
policy.onConnect(::send)
|
||||
}
|
||||
|
||||
+11
-11
@@ -24,37 +24,37 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
|
||||
interface IEventStore : AutoCloseable {
|
||||
fun insert(event: Event)
|
||||
suspend fun insert(event: Event)
|
||||
|
||||
interface ITransaction {
|
||||
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,
|
||||
onEach: (T) -> Unit,
|
||||
)
|
||||
|
||||
fun <T : Event> query(
|
||||
suspend fun <T : Event> query(
|
||||
filters: List<Filter>,
|
||||
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()
|
||||
}
|
||||
|
||||
+16
-12
@@ -34,33 +34,37 @@ class EventStore(
|
||||
) : IEventStore {
|
||||
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,
|
||||
onEach: (T) -> Unit,
|
||||
) = store.query(filter, onEach)
|
||||
|
||||
override fun <T : Event> query(
|
||||
override suspend fun <T : Event> query(
|
||||
filters: List<Filter>,
|
||||
onEach: (T) -> Unit,
|
||||
) = 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()
|
||||
}
|
||||
|
||||
+2
-2
@@ -22,10 +22,10 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
|
||||
fun SQLiteEventStore.explainQuery(
|
||||
suspend fun SQLiteEventStore.explainQuery(
|
||||
sql: String,
|
||||
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(
|
||||
sql: String,
|
||||
|
||||
+135
@@ -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() }
|
||||
}
|
||||
}
|
||||
+63
-60
@@ -34,24 +34,18 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.store.IEventStore
|
||||
import com.vitorpamplona.quartz.nip40Expiration.isExpired
|
||||
import com.vitorpamplona.quartz.utils.EventFactory
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class SQLiteEventStore(
|
||||
val driver: SQLiteDriver = BundledSQLiteDriver(),
|
||||
val dbName: String? = "events.db",
|
||||
val relay: NormalizedRelayUrl? = null,
|
||||
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
|
||||
val numReaders: Int = 4,
|
||||
) {
|
||||
companion object {
|
||||
const val DATABASE_VERSION = 2
|
||||
}
|
||||
|
||||
val connection: SQLiteConnection by lazy {
|
||||
openAndConfigure()
|
||||
}
|
||||
|
||||
val seedModule = SeedModule()
|
||||
|
||||
val fullTextSearchModule = FullTextSearchModule()
|
||||
@@ -89,23 +83,30 @@ class SQLiteEventStore(
|
||||
fullTextSearchModule,
|
||||
)
|
||||
|
||||
private fun openAndConfigure(): SQLiteConnection {
|
||||
val db = driver.open(dbName ?: ":memory:")
|
||||
|
||||
// 32MB memory cache
|
||||
val pool: SQLiteConnectionPool by lazy {
|
||||
SQLiteConnectionPool(
|
||||
driver = driver,
|
||||
dbName = dbName,
|
||||
numReaders = numReaders,
|
||||
onConfigure = { db ->
|
||||
// 32MB memory cache (per-connection).
|
||||
db.execSQL("PRAGMA cache_size=-32000;")
|
||||
|
||||
// makes sure the FKs are sane
|
||||
// Make sure the FKs are sane (per-connection).
|
||||
db.execSQL("PRAGMA foreign_keys = ON;")
|
||||
|
||||
// SQLite implements mutations by appending them to a log, which it occasionally
|
||||
// compacts into the database. This is called Write-Ahead Logging (WAL)
|
||||
// SQLite implements mutations by appending them to a log,
|
||||
// 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;")
|
||||
|
||||
// The DB can be corrupted if the OS is shutdown before sync, which generally
|
||||
// doesn't happen on Android
|
||||
// The DB can be corrupted if the OS shuts down before
|
||||
// sync, which generally doesn't happen on Android.
|
||||
db.execSQL("PRAGMA synchronous = OFF;")
|
||||
|
||||
},
|
||||
onMigrate = { db ->
|
||||
val currentVersion = getUserVersion(db)
|
||||
if (currentVersion == 0) {
|
||||
db.transaction {
|
||||
@@ -118,8 +119,8 @@ class SQLiteEventStore(
|
||||
setUserVersion(this, DATABASE_VERSION)
|
||||
}
|
||||
}
|
||||
|
||||
return db
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun getUserVersion(db: SQLiteConnection): Int =
|
||||
@@ -159,24 +160,23 @@ class SQLiteEventStore(
|
||||
}
|
||||
}
|
||||
|
||||
fun clearDB() {
|
||||
modules.reversed().forEach { it.deleteAll(connection) }
|
||||
suspend fun clearDB() =
|
||||
pool.useWriter { db ->
|
||||
modules.reversed().forEach { it.deleteAll(db) }
|
||||
}
|
||||
|
||||
suspend fun vacuum() {
|
||||
suspend fun vacuum() =
|
||||
pool.useWriter { db ->
|
||||
// VACUUM: Rebuilds the database file, reclaiming unused space
|
||||
// and reducing fragmentation.
|
||||
withContext(Dispatchers.IO) {
|
||||
connection.execSQL("VACUUM")
|
||||
}
|
||||
db.execSQL("VACUUM")
|
||||
}
|
||||
|
||||
suspend fun analyse() {
|
||||
suspend fun analyse() =
|
||||
pool.useWriter { db ->
|
||||
// ANALYZE: Collects statistics about tables and indices
|
||||
// to help the query planner optimize queries.
|
||||
withContext(Dispatchers.IO) {
|
||||
connection.execSQL("ANALYZE")
|
||||
}
|
||||
db.execSQL("ANALYZE")
|
||||
}
|
||||
|
||||
private fun innerInsertEvent(
|
||||
@@ -190,14 +190,16 @@ class SQLiteEventStore(
|
||||
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.kind.isEphemeral()) return
|
||||
|
||||
connection.transaction {
|
||||
pool.useWriter { db ->
|
||||
db.transaction {
|
||||
innerInsertEvent(event, this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inner class Transaction(
|
||||
val db: SQLiteConnection,
|
||||
@@ -210,64 +212,65 @@ class SQLiteEventStore(
|
||||
}
|
||||
}
|
||||
|
||||
fun transaction(body: Transaction.() -> Unit) {
|
||||
connection.transaction {
|
||||
suspend fun transaction(body: Transaction.() -> Unit) {
|
||||
pool.useWriter { db ->
|
||||
db.transaction {
|
||||
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,
|
||||
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>,
|
||||
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,
|
||||
onEach: (RawEvent) -> Unit,
|
||||
) = queryBuilder.rawQuery(filter, connection, onEach)
|
||||
) = pool.useReader { queryBuilder.rawQuery(filter, it, onEach) }
|
||||
|
||||
fun rawQuery(
|
||||
suspend fun rawQuery(
|
||||
filters: List<Filter>,
|
||||
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) {
|
||||
queryBuilder.delete(filter, connection)
|
||||
suspend fun delete(filter: Filter) = pool.useWriter { queryBuilder.delete(filter, it) }
|
||||
|
||||
suspend fun delete(filters: List<Filter>) = pool.useWriter { queryBuilder.delete(filters, it) }
|
||||
|
||||
suspend fun delete(id: HexKey): Int =
|
||||
pool.useWriter { db ->
|
||||
db.execSQL("DELETE FROM event_headers WHERE id = ?", arrayOf(id))
|
||||
db.changes()
|
||||
}
|
||||
|
||||
fun delete(filters: List<Filter>) {
|
||||
queryBuilder.delete(filters, connection)
|
||||
}
|
||||
suspend fun deleteExpiredEvents() = pool.useWriter { expirationModule.deleteExpiredEvents(it) }
|
||||
|
||||
fun delete(id: HexKey): Int {
|
||||
connection.execSQL("DELETE FROM event_headers WHERE id = ?", arrayOf(id))
|
||||
return connection.changes()
|
||||
}
|
||||
|
||||
fun deleteExpiredEvents() = expirationModule.deleteExpiredEvents(connection)
|
||||
fun close() = pool.close()
|
||||
}
|
||||
|
||||
class RawEvent(
|
||||
|
||||
+4
-4
@@ -24,7 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
fun <T : Event> EventStore.assertQuery(
|
||||
suspend fun <T : Event> EventStore.assertQuery(
|
||||
expected: T?,
|
||||
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>,
|
||||
filter: Filter,
|
||||
) {
|
||||
@@ -53,7 +53,7 @@ fun <T : Event> EventStore.assertQuery(
|
||||
}
|
||||
}
|
||||
|
||||
fun <T : Event> SQLiteEventStore.assertQuery(
|
||||
suspend fun <T : Event> SQLiteEventStore.assertQuery(
|
||||
expected: T?,
|
||||
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>,
|
||||
filter: Filter,
|
||||
) {
|
||||
|
||||
+6
-2
@@ -307,10 +307,14 @@ class BasicTest : BaseDBTest() {
|
||||
// modules.forEach { it.create(db) }. Pre-fix, FullTextSearchModule
|
||||
// left dummy_fts3/4/5 tables behind on first probe, so the
|
||||
// second create() would throw "already exists".
|
||||
// Drive the module re-create against the writer connection
|
||||
// (drop + create touches schema, so we need exclusive access).
|
||||
db.store.pool.useWriter { conn ->
|
||||
db.store.modules
|
||||
.reversed()
|
||||
.forEach { it.drop(db.store.connection) }
|
||||
db.store.modules.forEach { it.create(db.store.connection) }
|
||||
.forEach { it.drop(conn) }
|
||||
db.store.modules.forEach { it.create(conn) }
|
||||
}
|
||||
|
||||
// After re-creation the store is still usable.
|
||||
val note = signer.sign(TextNoteEvent.build("test1"))
|
||||
|
||||
+5
-2
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip40Expiration.isExpired
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
@@ -56,7 +57,8 @@ class LargeDBTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertHeavyEvent() {
|
||||
fun insertHeavyEvent() =
|
||||
runBlocking {
|
||||
events.first { it.id == "3f34b8cb682307ec11753de4669ce8948e95fd6fb360d79136446c5547fd235e" }.let { event ->
|
||||
try {
|
||||
db.insert(event)
|
||||
@@ -67,7 +69,8 @@ class LargeDBTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertDatabase() {
|
||||
fun insertDatabase() =
|
||||
runBlocking {
|
||||
events.forEach { event ->
|
||||
try {
|
||||
db.insert(event)
|
||||
|
||||
+2
-2
@@ -37,9 +37,9 @@ class QueryAssemblerTest : BaseDBTest() {
|
||||
val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14"
|
||||
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
|
||||
fun testEmpty() =
|
||||
|
||||
+16
-13
@@ -99,7 +99,7 @@ open class FsEventStore(
|
||||
// Insert
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
override fun insert(event: Event) =
|
||||
override suspend fun insert(event: Event) =
|
||||
lockManager.withWriteLock {
|
||||
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 {
|
||||
val txn =
|
||||
object : IEventStore.ITransaction {
|
||||
@@ -277,13 +277,13 @@ open class FsEventStore(
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@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>()
|
||||
query<T>(filter) { out.add(it) }
|
||||
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 out = mutableListOf<T>()
|
||||
filters.forEach { f ->
|
||||
@@ -292,7 +292,7 @@ open class FsEventStore(
|
||||
return out
|
||||
}
|
||||
|
||||
override fun <T : Event> query(
|
||||
override suspend fun <T : Event> query(
|
||||
filter: Filter,
|
||||
onEach: (T) -> Unit,
|
||||
) {
|
||||
@@ -311,7 +311,7 @@ open class FsEventStore(
|
||||
}
|
||||
}
|
||||
|
||||
override fun <T : Event> query(
|
||||
override suspend fun <T : Event> query(
|
||||
filters: List<Filter>,
|
||||
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
|
||||
query<Event>(filter) { n++ }
|
||||
return n
|
||||
}
|
||||
|
||||
override fun count(filters: List<Filter>): Int {
|
||||
override suspend fun count(filters: List<Filter>): Int {
|
||||
var n = 0
|
||||
query<Event>(filters) { n++ }
|
||||
return n
|
||||
@@ -343,7 +343,7 @@ open class FsEventStore(
|
||||
* entire store. This is asymmetric with `query(Filter())` which
|
||||
* intentionally returns every event — same contract as `SQLiteEventStore`.
|
||||
*/
|
||||
override fun delete(filter: Filter) =
|
||||
override suspend fun delete(filter: Filter) =
|
||||
lockManager.withWriteLock {
|
||||
if (filter.isEmpty()) return@withWriteLock
|
||||
val ids = ArrayList<HexKey>()
|
||||
@@ -352,7 +352,7 @@ open class FsEventStore(
|
||||
}
|
||||
|
||||
/** See [delete] for the empty-filter contract. */
|
||||
override fun delete(filters: List<Filter>) =
|
||||
override suspend fun delete(filters: List<Filter>) =
|
||||
lockManager.withWriteLock {
|
||||
val nonEmpty = filters.filterNot { it.isEmpty() }
|
||||
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. */
|
||||
fun delete(id: HexKey): Int =
|
||||
suspend fun delete(id: HexKey): Int =
|
||||
lockManager.withWriteLock {
|
||||
deleteLocked(id)
|
||||
}
|
||||
@@ -408,7 +408,10 @@ open class FsEventStore(
|
||||
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
|
||||
* `expiration < unixepoch()` predicate (note: strict `<`, not `<=`).
|
||||
*/
|
||||
override fun deleteExpiredEvents() =
|
||||
override suspend fun deleteExpiredEvents() =
|
||||
lockManager.withWriteLock {
|
||||
if (!Files.isDirectory(layout.idxExpiresAt)) return@withWriteLock
|
||||
val now = now()
|
||||
|
||||
+22
-4
@@ -67,7 +67,7 @@ internal class FsLockManager(
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> withWriteLock(body: () -> T): T {
|
||||
fun acquireWriteLock() {
|
||||
inProcessLock.lock()
|
||||
try {
|
||||
// Only the outermost re-entry actually touches the file lock.
|
||||
@@ -83,18 +83,36 @@ internal class FsLockManager(
|
||||
channel = ch
|
||||
fileLock = l
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
inProcessLock.unlock()
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
fun releaseWriteLock() {
|
||||
try {
|
||||
return body()
|
||||
} finally {
|
||||
if (inProcessLock.holdCount == 1) {
|
||||
releaseFileLock()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
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() {
|
||||
inProcessLock.lock()
|
||||
try {
|
||||
|
||||
+25
-12
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
@@ -86,7 +87,8 @@ class FsDeletionTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `kind-5 cascade-deletes a target by id`() {
|
||||
fun `kind-5 cascade-deletes a target by id`() =
|
||||
runBlocking {
|
||||
val n1 = note("one", 10)
|
||||
val n2 = note("two", 20)
|
||||
store.insert(n1)
|
||||
@@ -101,7 +103,8 @@ class FsDeletionTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deletion blocks re-insertion of the same id`() {
|
||||
fun `deletion blocks re-insertion of the same id`() =
|
||||
runBlocking {
|
||||
val n1 = note("one", 10)
|
||||
store.insert(n1)
|
||||
|
||||
@@ -113,7 +116,8 @@ class FsDeletionTest {
|
||||
}
|
||||
|
||||
@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`() =
|
||||
runBlocking {
|
||||
// other signer authors a note
|
||||
val theirs = note("not yours", 10, signer = otherSigner)
|
||||
store.insert(theirs)
|
||||
@@ -141,7 +145,8 @@ class FsDeletionTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `kind-5 by address cascades addressable slot`() {
|
||||
fun `kind-5 by address cascades addressable slot`() =
|
||||
runBlocking {
|
||||
val v1 = article("intro", "draft 1", 10)
|
||||
val v2 = article("intro", "draft 2", 20)
|
||||
store.insert(v1)
|
||||
@@ -158,7 +163,8 @@ class FsDeletionTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer event at a deleted address may pass the cutoff`() {
|
||||
fun `newer event at a deleted address may pass the cutoff`() =
|
||||
runBlocking {
|
||||
val v1 = article("intro", "draft 1", 10)
|
||||
store.insert(v1)
|
||||
|
||||
@@ -174,7 +180,8 @@ class FsDeletionTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `older event at a deleted address is blocked by cutoff`() {
|
||||
fun `older event at a deleted address is blocked by cutoff`() =
|
||||
runBlocking {
|
||||
val v1 = article("intro", "draft 1", 10)
|
||||
store.insert(v1)
|
||||
|
||||
@@ -188,7 +195,8 @@ class FsDeletionTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `equal-timestamp event at a deleted address is blocked`() {
|
||||
fun `equal-timestamp event at a deleted address is blocked`() =
|
||||
runBlocking {
|
||||
val v = article("intro", "v", 10)
|
||||
store.insert(v)
|
||||
|
||||
@@ -205,7 +213,8 @@ class FsDeletionTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `later kind-5 raises the address cutoff`() {
|
||||
fun `later kind-5 raises the address cutoff`() =
|
||||
runBlocking {
|
||||
val v = article("intro", "v", 10)
|
||||
store.insert(v)
|
||||
|
||||
@@ -224,7 +233,8 @@ class FsDeletionTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `earlier kind-5 does not lower an existing stronger cutoff`() {
|
||||
fun `earlier kind-5 does not lower an existing stronger cutoff`() =
|
||||
runBlocking {
|
||||
val v1 = article("slug", "v1", 10)
|
||||
val v2 = article("slug", "v2", 20)
|
||||
store.insert(v1)
|
||||
@@ -254,7 +264,8 @@ class FsDeletionTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `deletion event itself is indexed and queryable`() {
|
||||
fun `deletion event itself is indexed and queryable`() =
|
||||
runBlocking {
|
||||
val n = note("x", 10)
|
||||
store.insert(n)
|
||||
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n), createdAt = 20))
|
||||
@@ -269,7 +280,8 @@ class FsDeletionTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `non-author address deletion does not block legitimate addressable inserts`() {
|
||||
fun `non-author address deletion does not block legitimate addressable inserts`() =
|
||||
runBlocking {
|
||||
// `otherSigner` (call them Bob) authors an addressable; the
|
||||
// default `signer` (a stranger relative to Bob) then publishes a
|
||||
// kind-5 with an `a` tag pointing at Bob's address. NIP-09 says
|
||||
@@ -294,7 +306,8 @@ class FsDeletionTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `id tombstone is a hardlink to the kind-5 event`() {
|
||||
fun `id tombstone is a hardlink to the kind-5 event`() =
|
||||
runBlocking {
|
||||
val n = note("x", 10)
|
||||
store.insert(n)
|
||||
val del = signer.sign<DeletionEvent>(DeletionEvent.build(listOf(n), createdAt = 20))
|
||||
|
||||
+23
-11
@@ -24,6 +24,7 @@ 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.runBlocking
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
@@ -63,7 +64,8 @@ class FsEventStoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `insert and query by id round-trips`() {
|
||||
fun `insert and query by id round-trips`() =
|
||||
runBlocking {
|
||||
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("hello"))
|
||||
|
||||
store.insert(note)
|
||||
@@ -76,7 +78,8 @@ class FsEventStoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canonical path uses 2-char sharding`() {
|
||||
fun `canonical path uses 2-char sharding`() =
|
||||
runBlocking {
|
||||
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("shard me"))
|
||||
store.insert(note)
|
||||
|
||||
@@ -86,13 +89,15 @@ class FsEventStoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query returns empty when nothing inserted`() {
|
||||
fun `query returns empty when nothing inserted`() =
|
||||
runBlocking {
|
||||
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`() {
|
||||
fun `delete by id removes the file`() =
|
||||
runBlocking {
|
||||
val note = signer.sign<TextNoteEvent>(TextNoteEvent.build("to-delete"))
|
||||
store.insert(note)
|
||||
assertEquals(1, store.count(Filter(ids = listOf(note.id))))
|
||||
@@ -103,13 +108,15 @@ class FsEventStoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete returns 0 when event absent`() {
|
||||
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`() {
|
||||
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)
|
||||
@@ -122,7 +129,8 @@ class FsEventStoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `insert of duplicate id is a no-op`() {
|
||||
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
|
||||
@@ -130,7 +138,8 @@ class FsEventStoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ephemeral events are not persisted`() {
|
||||
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 =
|
||||
@@ -145,7 +154,8 @@ class FsEventStoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ids that share the same 4-char shard both persist`() {
|
||||
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))
|
||||
@@ -170,7 +180,8 @@ class FsEventStoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete with empty filter is safe`() {
|
||||
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)
|
||||
@@ -187,7 +198,8 @@ class FsEventStoreTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `staging dir is cleared on init`() {
|
||||
fun `staging dir is cleared on init`() =
|
||||
runBlocking {
|
||||
val staging = root.resolve(".staging")
|
||||
val leftover = Files.createTempFile(staging, "crash-", ".json")
|
||||
assertTrue(leftover.exists())
|
||||
|
||||
+5
-2
@@ -26,6 +26,7 @@ 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.runBlocking
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
@@ -54,7 +55,8 @@ class FsEventToJsonTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default formatter writes compact JSON one line`() {
|
||||
fun `default formatter writes compact JSON one line`() =
|
||||
runBlocking {
|
||||
val store = FsEventStore(root)
|
||||
try {
|
||||
val n =
|
||||
@@ -77,7 +79,8 @@ class FsEventToJsonTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pretty formatter writes multi-line indented JSON and round-trips`() {
|
||||
fun `pretty formatter writes multi-line indented JSON and round-trips`() =
|
||||
runBlocking {
|
||||
val store =
|
||||
FsEventStore(
|
||||
root,
|
||||
|
||||
+17
-8
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
@@ -81,7 +82,8 @@ class FsExpirationTest {
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `event with future expiration is accepted and indexed`() {
|
||||
fun `event with future expiration is accepted and indexed`() =
|
||||
runBlocking {
|
||||
clockNow = 1_000
|
||||
val e = expiringNote("future", createdAt = 500, expiresAt = 2_000)
|
||||
store.insert(e)
|
||||
@@ -96,7 +98,8 @@ class FsExpirationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event already expired at insert time is rejected`() {
|
||||
fun `event already expired at insert time is rejected`() =
|
||||
runBlocking {
|
||||
clockNow = 5_000
|
||||
val e = expiringNote("dead-on-arrival", createdAt = 1_000, expiresAt = 4_000)
|
||||
store.insert(e)
|
||||
@@ -106,7 +109,8 @@ class FsExpirationTest {
|
||||
}
|
||||
|
||||
@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)`() =
|
||||
runBlocking {
|
||||
clockNow = 5_000
|
||||
val e = expiringNote("just-now", createdAt = 1_000, expiresAt = 5_000)
|
||||
store.insert(e)
|
||||
@@ -115,7 +119,8 @@ class FsExpirationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-positive expiration is ignored`() {
|
||||
fun `non-positive expiration is ignored`() =
|
||||
runBlocking {
|
||||
clockNow = 5_000
|
||||
val zero = expiringNote("zero", createdAt = 1, expiresAt = 0)
|
||||
val neg = expiringNote("neg", createdAt = 2, expiresAt = -1)
|
||||
@@ -130,7 +135,8 @@ class FsExpirationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteExpiredEvents sweeps everything past now`() {
|
||||
fun `deleteExpiredEvents sweeps everything past now`() =
|
||||
runBlocking {
|
||||
clockNow = 1_000
|
||||
val a = expiringNote("a", createdAt = 100, expiresAt = 500) // already expired
|
||||
val b = expiringNote("b", createdAt = 200, expiresAt = 999) // expired in past
|
||||
@@ -152,7 +158,8 @@ class FsExpirationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sweep uses strict less-than parity with SQLite`() {
|
||||
fun `sweep uses strict less-than parity with SQLite`() =
|
||||
runBlocking {
|
||||
// SQLite trigger: WHERE NEW.expiration <= unixepoch() (insert)
|
||||
// SQLite sweep: WHERE expiration < unixepoch() (delete)
|
||||
// Insert-time uses inclusive <=, sweep uses strict <.
|
||||
@@ -170,7 +177,8 @@ class FsExpirationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sweep removes index entries too`() {
|
||||
fun `sweep removes index entries too`() =
|
||||
runBlocking {
|
||||
clockNow = 50
|
||||
val e = expiringNote("x", createdAt = 1, expiresAt = 100)
|
||||
store.insert(e)
|
||||
@@ -188,7 +196,8 @@ class FsExpirationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `events without expiration are unaffected by sweep`() {
|
||||
fun `events without expiration are unaffected by sweep`() =
|
||||
runBlocking {
|
||||
clockNow = 100
|
||||
val plain =
|
||||
signer.sign<Event>(
|
||||
|
||||
+37
-21
@@ -24,6 +24,10 @@ 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.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
@@ -65,7 +69,8 @@ class FsMaintenanceTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `lock file is created on open`() {
|
||||
fun `lock file is created on open`() =
|
||||
runBlocking {
|
||||
assertTrue(root.resolve(".lock").exists())
|
||||
}
|
||||
|
||||
@@ -74,7 +79,8 @@ class FsMaintenanceTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `transaction commits all inserts on success`() {
|
||||
fun `transaction commits all inserts on success`() =
|
||||
runBlocking {
|
||||
val a = note("a", 1)
|
||||
val b = note("b", 2)
|
||||
val c = note("c", 3)
|
||||
@@ -90,7 +96,8 @@ class FsMaintenanceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transaction propagates exceptions and stops processing`() {
|
||||
fun `transaction propagates exceptions and stops processing`() =
|
||||
runBlocking {
|
||||
val a = note("a", 1)
|
||||
val b = note("b", 2)
|
||||
val c = note("c", 3)
|
||||
@@ -114,16 +121,20 @@ class FsMaintenanceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transaction is re-entrant on the same thread`() {
|
||||
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)
|
||||
// Call an outer-locking method from within the transaction.
|
||||
store.deleteExpiredEvents()
|
||||
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))))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -131,7 +142,8 @@ class FsMaintenanceTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `scrub rebuilds idx entries after a manual wipe`() {
|
||||
fun `scrub rebuilds idx entries after a manual wipe`() =
|
||||
runBlocking {
|
||||
val a = note("hello bitcoin", 10)
|
||||
val b = note("nostr stuff", 20)
|
||||
store.insert(a)
|
||||
@@ -155,7 +167,8 @@ class FsMaintenanceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scrub leaves replaceable slot intact`() {
|
||||
fun `scrub leaves replaceable slot intact`() =
|
||||
runBlocking {
|
||||
// Replaceable slots pin events via hardlink even without the
|
||||
// canonical. Scrub must not wipe slots.
|
||||
val meta =
|
||||
@@ -178,7 +191,8 @@ class FsMaintenanceTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `compact drops idx entries whose canonical is gone`() {
|
||||
fun `compact drops idx entries whose canonical is gone`() =
|
||||
runBlocking {
|
||||
val a = note("x", 10)
|
||||
store.insert(a)
|
||||
|
||||
@@ -195,7 +209,8 @@ class FsMaintenanceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compact leaves valid entries alone`() {
|
||||
fun `compact leaves valid entries alone`() =
|
||||
runBlocking {
|
||||
val a = note("x", 10)
|
||||
store.insert(a)
|
||||
|
||||
@@ -211,13 +226,15 @@ class FsMaintenanceTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `close is idempotent`() {
|
||||
fun `close is idempotent`() =
|
||||
runBlocking {
|
||||
store.close()
|
||||
store.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reopen after close works`() {
|
||||
fun `reopen after close works`() =
|
||||
runBlocking {
|
||||
val a = note("a", 1)
|
||||
store.insert(a)
|
||||
store.close()
|
||||
@@ -235,22 +252,21 @@ class FsMaintenanceTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `concurrent inserts on two threads are both persisted`() {
|
||||
fun `concurrent inserts on two threads are both persisted`() =
|
||||
runBlocking {
|
||||
val events = (1..20).map { note("n$it", it.toLong()) }
|
||||
val half = events.size / 2
|
||||
|
||||
val t1 =
|
||||
Thread {
|
||||
// Two real threads via Dispatchers.IO so the in-process lock has to
|
||||
// arbitrate. join via coroutineScope.
|
||||
coroutineScope {
|
||||
launch(Dispatchers.IO) {
|
||||
events.take(half).forEach { store.insert(it) }
|
||||
}
|
||||
val t2 =
|
||||
Thread {
|
||||
launch(Dispatchers.IO) {
|
||||
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()
|
||||
assertEquals(events.map { it.id }.toSet(), got)
|
||||
|
||||
+44
-25
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
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). */
|
||||
private fun insertBoth(event: Event) {
|
||||
private suspend fun insertBoth(event: Event) {
|
||||
try {
|
||||
sqlite.insert(event)
|
||||
} catch (_: Throwable) {
|
||||
@@ -93,7 +94,7 @@ class FsParityTest {
|
||||
}
|
||||
|
||||
/** Assert both stores return the same ids (as a set) for the given filter. */
|
||||
private fun assertParity(
|
||||
private suspend fun assertParity(
|
||||
filter: Filter,
|
||||
message: String = "",
|
||||
) {
|
||||
@@ -103,7 +104,7 @@ class FsParityTest {
|
||||
}
|
||||
|
||||
/** Same, but expect a stable DESC-by-createdAt ordering. */
|
||||
private fun assertParityOrdered(
|
||||
private suspend fun assertParityOrdered(
|
||||
filter: Filter,
|
||||
message: String = "",
|
||||
) {
|
||||
@@ -135,25 +136,28 @@ class FsParityTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `id lookup matches`() {
|
||||
fun `id lookup matches`() =
|
||||
runBlocking {
|
||||
val n = note("hello", 10)
|
||||
insertBoth(n)
|
||||
assertParity(Filter(ids = listOf(n.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kind + author query matches`() {
|
||||
fun `kind + author query matches`() =
|
||||
runBlocking {
|
||||
val a = note("a", 1)
|
||||
val b = note("b", 2)
|
||||
val c = note("c", 3, s = otherSigner)
|
||||
listOf(a, b, c).forEach(::insertBoth)
|
||||
listOf(a, b, c).forEach { insertBoth(it) }
|
||||
|
||||
assertParityOrdered(Filter(kinds = listOf(1), authors = listOf(signer.pubKey)))
|
||||
assertParityOrdered(Filter(authors = listOf(signer.pubKey, otherSigner.pubKey)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `since until limit match`() {
|
||||
fun `since until limit match`() =
|
||||
runBlocking {
|
||||
repeat(10) { i -> insertBoth(note("n$i", i.toLong() + 1)) }
|
||||
assertParityOrdered(Filter(authors = listOf(signer.pubKey), since = 4, until = 8))
|
||||
assertParityOrdered(Filter(authors = listOf(signer.pubKey), limit = 3))
|
||||
@@ -164,7 +168,8 @@ class FsParityTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `single-letter tag queries match`() {
|
||||
fun `single-letter tag queries match`() =
|
||||
runBlocking {
|
||||
val tagged =
|
||||
signer.sign<Event>(
|
||||
createdAt = 5,
|
||||
@@ -185,7 +190,8 @@ class FsParityTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `replaceable newer wins parity`() {
|
||||
fun `replaceable newer wins parity`() =
|
||||
runBlocking {
|
||||
val v1 =
|
||||
signer.sign<Event>(
|
||||
createdAt = 100,
|
||||
@@ -208,7 +214,8 @@ class FsParityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaceable older rejected parity`() {
|
||||
fun `replaceable older rejected parity`() =
|
||||
runBlocking {
|
||||
val newer =
|
||||
signer.sign<Event>(createdAt = 200, kind = 0, tags = emptyArray(), content = "{\"name\":\"new\"}")
|
||||
val older =
|
||||
@@ -220,7 +227,8 @@ class FsParityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addressable d-tag dedup parity`() {
|
||||
fun `addressable d-tag dedup parity`() =
|
||||
runBlocking {
|
||||
val v1 = article("intro", "v1", 10)
|
||||
val v2 = article("intro", "v2", 20)
|
||||
val v3 = article("about", "bio", 15)
|
||||
@@ -232,7 +240,8 @@ class FsParityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaceable same-createdAt lexical id tiebreaker parity`() {
|
||||
fun `replaceable same-createdAt lexical id tiebreaker parity`() =
|
||||
runBlocking {
|
||||
// Two kind-0 events with identical createdAt produce different ids
|
||||
// because their content differs. NIP-01 says the lexically smaller
|
||||
// id wins on a tie. Both stores must agree, regardless of insertion
|
||||
@@ -266,7 +275,8 @@ class FsParityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addressable same-createdAt lexical id tiebreaker parity`() {
|
||||
fun `addressable same-createdAt lexical id tiebreaker parity`() =
|
||||
runBlocking {
|
||||
val a = article("tie", "version a", 100)
|
||||
val b = article("tie", "version b", 100)
|
||||
|
||||
@@ -288,7 +298,8 @@ class FsParityTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `deletion by id parity`() {
|
||||
fun `deletion by id parity`() =
|
||||
runBlocking {
|
||||
val a = note("a", 10)
|
||||
val b = note("b", 20)
|
||||
insertBoth(a)
|
||||
@@ -307,7 +318,8 @@ class FsParityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deletion by address parity`() {
|
||||
fun `deletion by address parity`() =
|
||||
runBlocking {
|
||||
val v = article("intro", "v1", 10)
|
||||
insertBoth(v)
|
||||
|
||||
@@ -328,7 +340,8 @@ class FsParityTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `expiration sweep parity`() {
|
||||
fun `expiration sweep parity`() =
|
||||
runBlocking {
|
||||
// Build events with future-then-past expirations relative to now.
|
||||
val now =
|
||||
com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -364,7 +377,8 @@ class FsParityTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `search parity`() {
|
||||
fun `search parity`() =
|
||||
runBlocking {
|
||||
val a = note("hello bitcoin", 1)
|
||||
val b = note("nostr only", 2)
|
||||
val c = note("bitcoin and nostr", 3)
|
||||
@@ -384,13 +398,14 @@ class FsParityTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `count parity across mixed stream`() {
|
||||
fun `count parity across mixed stream`() =
|
||||
runBlocking {
|
||||
listOf(
|
||||
note("a", 1),
|
||||
note("b", 2),
|
||||
note("c", 3),
|
||||
note("from-other", 4, s = otherSigner),
|
||||
).forEach(::insertBoth)
|
||||
).forEach { insertBoth(it) }
|
||||
|
||||
val filter = Filter(authors = listOf(signer.pubKey))
|
||||
assertEquals(sqlite.count(filter), fs.count(filter))
|
||||
@@ -401,7 +416,8 @@ class FsParityTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `kitchen sink scenario`() {
|
||||
fun `kitchen sink scenario`() =
|
||||
runBlocking {
|
||||
// Notes
|
||||
val n1 = note("first", 1)
|
||||
val n2 = note("second", 2)
|
||||
@@ -417,7 +433,7 @@ class FsParityTest {
|
||||
// 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.
|
||||
assertParity(Filter(ids = listOf(n1.id)), "n1 deleted")
|
||||
@@ -432,7 +448,8 @@ class FsParityTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `multi-filter union parity`() {
|
||||
fun `multi-filter union parity`() =
|
||||
runBlocking {
|
||||
val a = note("a", 1)
|
||||
val b = note("b", 2, s = otherSigner)
|
||||
insertBoth(a)
|
||||
@@ -455,7 +472,8 @@ class FsParityTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `delete by filter parity`() {
|
||||
fun `delete by filter parity`() =
|
||||
runBlocking {
|
||||
val toKill = note("dead", 5)
|
||||
val survivor = note("alive", 6)
|
||||
insertBoth(toKill)
|
||||
@@ -472,13 +490,14 @@ class FsParityTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `helper sanity - empty stores agree`() {
|
||||
fun `helper sanity - empty stores agree`() =
|
||||
runBlocking {
|
||||
assertParity(Filter(authors = listOf(signer.pubKey)))
|
||||
assertParity(Filter(kinds = listOf(1)))
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
private fun debugDump(label: String): String {
|
||||
private suspend fun debugDump(label: String): String {
|
||||
val sqIds =
|
||||
sqlite
|
||||
.query<Event>(Filter(authors = listOf(signer.pubKey, otherSigner.pubKey)))
|
||||
|
||||
+47
-25
@@ -25,6 +25,7 @@ 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.runBlocking
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
@@ -71,18 +72,20 @@ class FsQueryTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `results ordered by created_at DESC`() {
|
||||
fun `results ordered by created_at DESC`() =
|
||||
runBlocking {
|
||||
val a = signA("a", 1)
|
||||
val b = signA("b", 3)
|
||||
val c = signA("c", 2)
|
||||
listOf(a, b, c).forEach(store::insert)
|
||||
listOf(a, b, c).forEach { store.insert(it) }
|
||||
|
||||
val got = store.query<TextNoteEvent>(Filter(authors = listOf(signerA.pubKey)))
|
||||
assertEquals(listOf(b.id, c.id, a.id), got.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `limit caps the result count`() {
|
||||
fun `limit caps the result count`() =
|
||||
runBlocking {
|
||||
repeat(5) { i -> store.insert(signA("n$i", i.toLong() + 1)) }
|
||||
val got = store.query<TextNoteEvent>(Filter(authors = listOf(signerA.pubKey), limit = 2))
|
||||
assertEquals(2, got.size)
|
||||
@@ -92,7 +95,8 @@ class FsQueryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `limit of zero returns empty`() {
|
||||
fun `limit of zero returns empty`() =
|
||||
runBlocking {
|
||||
store.insert(signA("x", 1))
|
||||
assertEquals(emptyList(), store.query<TextNoteEvent>(Filter(authors = listOf(signerA.pubKey), limit = 0)))
|
||||
}
|
||||
@@ -102,7 +106,8 @@ class FsQueryTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `author filter isolates one user`() {
|
||||
fun `author filter isolates one user`() =
|
||||
runBlocking {
|
||||
val a = signA("from-a", 1)
|
||||
val b = signB("from-b", 2)
|
||||
store.insert(a)
|
||||
@@ -113,7 +118,8 @@ class FsQueryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `author filter with multiple authors unions them`() {
|
||||
fun `author filter with multiple authors unions them`() =
|
||||
runBlocking {
|
||||
val a = signA("a", 1)
|
||||
val b = signB("b", 2)
|
||||
store.insert(a)
|
||||
@@ -124,7 +130,8 @@ class FsQueryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kind filter returns only the requested kinds`() {
|
||||
fun `kind filter returns only the requested kinds`() =
|
||||
runBlocking {
|
||||
// Build two events of different kinds.
|
||||
val note = signA("note", 1)
|
||||
val ephemeralKinds = signerA.sign<Event>(createdAt = 2, kind = 30023, tags = arrayOf(arrayOf("d", "slug")), content = "article")
|
||||
@@ -139,7 +146,8 @@ class FsQueryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kind + author intersect via post-filter`() {
|
||||
fun `kind + author intersect via post-filter`() =
|
||||
runBlocking {
|
||||
val a = signA("a", 1)
|
||||
val b = signB("b", 2)
|
||||
store.insert(a)
|
||||
@@ -154,7 +162,8 @@ class FsQueryTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `tag filter matches single-letter tags`() {
|
||||
fun `tag filter matches single-letter tags`() =
|
||||
runBlocking {
|
||||
val tagged =
|
||||
signerA.sign<Event>(
|
||||
createdAt = 10,
|
||||
@@ -171,18 +180,20 @@ class FsQueryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tag OR within key returns union`() {
|
||||
fun `tag OR within key returns union`() =
|
||||
runBlocking {
|
||||
val t1 = signerA.sign<Event>(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "n")
|
||||
val t2 = signerA.sign<Event>(createdAt = 2, kind = 1, tags = arrayOf(arrayOf("t", "bitcoin")), content = "b")
|
||||
val t3 = signerA.sign<Event>(createdAt = 3, kind = 1, tags = arrayOf(arrayOf("t", "other")), content = "o")
|
||||
listOf(t1, t2, t3).forEach(store::insert)
|
||||
listOf(t1, t2, t3).forEach { store.insert(it) }
|
||||
|
||||
val got = store.query<Event>(Filter(tags = mapOf("t" to listOf("nostr", "bitcoin"))))
|
||||
assertEquals(setOf(t1.id, t2.id), got.map { it.id }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tagsAll across keys requires all matches`() {
|
||||
fun `tagsAll across keys requires all matches`() =
|
||||
runBlocking {
|
||||
val both =
|
||||
signerA.sign<Event>(
|
||||
createdAt = 1,
|
||||
@@ -192,7 +203,7 @@ class FsQueryTest {
|
||||
)
|
||||
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")
|
||||
listOf(both, onlyT, onlyE).forEach(store::insert)
|
||||
listOf(both, onlyT, onlyE).forEach { store.insert(it) }
|
||||
|
||||
val got =
|
||||
store.query<Event>(
|
||||
@@ -208,7 +219,8 @@ class FsQueryTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `safe ASCII tag values get raw directory names`() {
|
||||
fun `safe ASCII tag values get raw directory names`() =
|
||||
runBlocking {
|
||||
val e =
|
||||
signerA.sign<Event>(
|
||||
createdAt = 10,
|
||||
@@ -224,7 +236,8 @@ class FsQueryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pubkey p-tag uses raw 64-hex directory name`() {
|
||||
fun `pubkey p-tag uses raw 64-hex directory name`() =
|
||||
runBlocking {
|
||||
// The motivating case: notifications. p-tags pointing at a
|
||||
// pubkey land under idx/tag/p/<pubkey>/ — no hash, directly
|
||||
// ls-able.
|
||||
@@ -243,7 +256,8 @@ class FsQueryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tag value with emoji falls back to hashed directory name`() {
|
||||
fun `tag value with emoji falls back to hashed directory name`() =
|
||||
runBlocking {
|
||||
val e =
|
||||
signerA.sign<Event>(
|
||||
createdAt = 10,
|
||||
@@ -261,7 +275,8 @@ class FsQueryTest {
|
||||
}
|
||||
|
||||
@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`() =
|
||||
runBlocking {
|
||||
val e =
|
||||
signerA.sign<Event>(
|
||||
createdAt = 10,
|
||||
@@ -277,7 +292,8 @@ class FsQueryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query round-trips for both raw and hashed values`() {
|
||||
fun `query round-trips for both raw and hashed values`() =
|
||||
runBlocking {
|
||||
// Each query must use the same naming rule as the writer or it
|
||||
// walks a directory that doesn't exist. Insert both a raw-safe
|
||||
// and a hash-required tag and verify they're both findable.
|
||||
@@ -308,7 +324,8 @@ class FsQueryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-single-letter tags are not reverse-indexed`() {
|
||||
fun `non-single-letter tags are not reverse-indexed`() =
|
||||
runBlocking {
|
||||
// SQLite parity: DefaultIndexingStrategy only indexes single-letter
|
||||
// tag names, so a tag-driven query for `mytag = foo` finds no
|
||||
// candidates. The event is still persisted and can be fetched via
|
||||
@@ -332,11 +349,12 @@ class FsQueryTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `since and until window filter`() {
|
||||
fun `since and until window filter`() =
|
||||
runBlocking {
|
||||
val e1 = signA("t1", 100)
|
||||
val e2 = signA("t2", 200)
|
||||
val e3 = signA("t3", 300)
|
||||
listOf(e1, e2, e3).forEach(store::insert)
|
||||
listOf(e1, e2, e3).forEach { store.insert(it) }
|
||||
|
||||
val got = store.query<TextNoteEvent>(Filter(since = 150, until = 250))
|
||||
assertEquals(listOf(e2.id), got.map { it.id })
|
||||
@@ -347,7 +365,8 @@ class FsQueryTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `count matches query size`() {
|
||||
fun `count matches query size`() =
|
||||
runBlocking {
|
||||
repeat(4) { i -> store.insert(signA("n$i", i.toLong() + 1)) }
|
||||
val filter = Filter(authors = listOf(signerA.pubKey))
|
||||
assertEquals(store.query<TextNoteEvent>(filter).size, store.count(filter))
|
||||
@@ -358,7 +377,8 @@ class FsQueryTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `insert creates hardlinks in every expected index dir`() {
|
||||
fun `insert creates hardlinks in every expected index dir`() =
|
||||
runBlocking {
|
||||
val tagged =
|
||||
signerA.sign<Event>(
|
||||
createdAt = 42,
|
||||
@@ -380,7 +400,8 @@ class FsQueryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete removes hardlinks so directories become empty`() {
|
||||
fun `delete removes hardlinks so directories become empty`() =
|
||||
runBlocking {
|
||||
val e = signerA.sign<Event>(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "x")
|
||||
store.insert(e)
|
||||
store.delete(e.id)
|
||||
@@ -405,7 +426,8 @@ class FsQueryTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `reopening the store preserves queryability`() {
|
||||
fun `reopening the store preserves queryability`() =
|
||||
runBlocking {
|
||||
val tagged = signerA.sign<Event>(createdAt = 1, kind = 1, tags = arrayOf(arrayOf("t", "nostr")), content = "x")
|
||||
store.insert(tagged)
|
||||
store.close()
|
||||
|
||||
+33
-16
@@ -25,6 +25,7 @@ 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.runBlocking
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
@@ -66,25 +67,29 @@ class FsSearchTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `tokenizer splits on whitespace and punctuation`() {
|
||||
fun `tokenizer splits on whitespace and punctuation`() =
|
||||
runBlocking {
|
||||
assertEquals(setOf("hello", "world"), FsSearchTokenizer.tokenize("hello, world!"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tokenizer is case insensitive`() {
|
||||
fun `tokenizer is case insensitive`() =
|
||||
runBlocking {
|
||||
assertEquals(FsSearchTokenizer.tokenize("Bitcoin"), FsSearchTokenizer.tokenize("BITCOIN"))
|
||||
assertEquals(FsSearchTokenizer.tokenize("Bitcoin"), FsSearchTokenizer.tokenize("bitcoin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tokenizer handles empty and punctuation-only strings`() {
|
||||
fun `tokenizer handles empty and punctuation-only strings`() =
|
||||
runBlocking {
|
||||
assertEquals(emptySet<String>(), FsSearchTokenizer.tokenize(""))
|
||||
assertEquals(emptySet<String>(), FsSearchTokenizer.tokenize("..."))
|
||||
assertEquals(emptySet<String>(), FsSearchTokenizer.tokenize(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tokenizer keeps unicode letters`() {
|
||||
fun `tokenizer keeps unicode letters`() =
|
||||
runBlocking {
|
||||
assertEquals(setOf("café", "über"), FsSearchTokenizer.tokenize("Café Über"))
|
||||
}
|
||||
|
||||
@@ -93,7 +98,8 @@ class FsSearchTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `searchable event creates one fts entry per unique token`() {
|
||||
fun `searchable event creates one fts entry per unique token`() =
|
||||
runBlocking {
|
||||
val n = note("bitcoin nostr bitcoin", ts = 100)
|
||||
store.insert(n)
|
||||
|
||||
@@ -109,7 +115,8 @@ class FsSearchTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-searchable event does not produce fts entries`() {
|
||||
fun `non-searchable event does not produce fts entries`() =
|
||||
runBlocking {
|
||||
val meta =
|
||||
signer.sign<MetadataEvent>(
|
||||
createdAt = 1,
|
||||
@@ -124,7 +131,8 @@ class FsSearchTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete removes fts entries`() {
|
||||
fun `delete removes fts entries`() =
|
||||
runBlocking {
|
||||
val n = note("bitcoin nostr", ts = 100)
|
||||
store.insert(n)
|
||||
store.delete(n.id)
|
||||
@@ -141,7 +149,8 @@ class FsSearchTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `single-token search returns the matching event`() {
|
||||
fun `single-token search returns the matching event`() =
|
||||
runBlocking {
|
||||
val a = note("bitcoin is fun", ts = 1)
|
||||
val b = note("nostr is also fun", ts = 2)
|
||||
store.insert(a)
|
||||
@@ -152,7 +161,8 @@ class FsSearchTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multi-token search is AND across tokens`() {
|
||||
fun `multi-token search is AND across tokens`() =
|
||||
runBlocking {
|
||||
val a = note("bitcoin only", ts = 1)
|
||||
val b = note("nostr only", ts = 2)
|
||||
val c = note("bitcoin and nostr", ts = 3)
|
||||
@@ -165,7 +175,8 @@ class FsSearchTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search results are ordered by createdAt DESC`() {
|
||||
fun `search results are ordered by createdAt DESC`() =
|
||||
runBlocking {
|
||||
val older = note("bitcoin first", ts = 10)
|
||||
val newer = note("bitcoin again", ts = 20)
|
||||
store.insert(older)
|
||||
@@ -176,14 +187,16 @@ class FsSearchTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search respects limit`() {
|
||||
fun `search respects limit`() =
|
||||
runBlocking {
|
||||
repeat(5) { i -> store.insert(note("bitcoin doc $i", ts = i.toLong() + 1)) }
|
||||
val got = store.query<TextNoteEvent>(Filter(search = "bitcoin", limit = 2))
|
||||
assertEquals(2, got.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search composes with kinds and authors via post-filter`() {
|
||||
fun `search composes with kinds and authors via post-filter`() =
|
||||
runBlocking {
|
||||
val match = note("bitcoin maximalism", ts = 5)
|
||||
store.insert(match)
|
||||
|
||||
@@ -201,7 +214,8 @@ class FsSearchTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search with no matching token returns empty`() {
|
||||
fun `search with no matching token returns empty`() =
|
||||
runBlocking {
|
||||
store.insert(note("nostr only", ts = 1))
|
||||
assertEquals(
|
||||
emptyList(),
|
||||
@@ -210,7 +224,8 @@ class FsSearchTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank search string is ignored`() {
|
||||
fun `blank search string is ignored`() =
|
||||
runBlocking {
|
||||
val a = note("anything", ts = 1)
|
||||
store.insert(a)
|
||||
// Blank search shouldn't drive by FTS — the planner falls through
|
||||
@@ -220,7 +235,8 @@ class FsSearchTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search survives reopen`() {
|
||||
fun `search survives reopen`() =
|
||||
runBlocking {
|
||||
val n = note("persistent token", ts = 100)
|
||||
store.insert(n)
|
||||
store.close()
|
||||
@@ -239,7 +255,8 @@ class FsSearchTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `fts entry is unlinked when event is deleted`() {
|
||||
fun `fts entry is unlinked when event is deleted`() =
|
||||
runBlocking {
|
||||
val n = note("unique-token-zzz", ts = 1)
|
||||
store.insert(n)
|
||||
assertTrue(root.resolve("idx/fts/unique").exists())
|
||||
|
||||
+35
-17
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
@@ -72,7 +73,8 @@ class FsSlotsTest {
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `newer replaceable evicts older`() {
|
||||
fun `newer replaceable evicts older`() =
|
||||
runBlocking {
|
||||
val v1 = metadata("old", 100)
|
||||
val v2 = metadata("new", 200)
|
||||
store.insert(v1)
|
||||
@@ -87,7 +89,8 @@ class FsSlotsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `older replaceable is rejected when newer exists`() {
|
||||
fun `older replaceable is rejected when newer exists`() =
|
||||
runBlocking {
|
||||
val newer = metadata("new", 200)
|
||||
val older = metadata("old", 100)
|
||||
store.insert(newer)
|
||||
@@ -102,7 +105,8 @@ class FsSlotsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `equal timestamp replaceable resolves by lexical id (NIP-01)`() {
|
||||
fun `equal timestamp replaceable resolves by lexical id (NIP-01)`() =
|
||||
runBlocking {
|
||||
val a = metadata("a", 100)
|
||||
val b = metadata("b", 100)
|
||||
// NIP-01 tiebreaker: when createdAt ties, the lexically smaller
|
||||
@@ -119,7 +123,8 @@ class FsSlotsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `equal timestamp replaceable rejects higher id when winner already present`() {
|
||||
fun `equal timestamp replaceable rejects higher id when winner already present`() =
|
||||
runBlocking {
|
||||
val a = metadata("a", 100)
|
||||
val b = metadata("b", 100)
|
||||
val (winner, loser) = if (a.id < b.id) a to b else b to a
|
||||
@@ -134,7 +139,8 @@ class FsSlotsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaceable slot file contains the current winner`() {
|
||||
fun `replaceable slot file contains the current winner`() =
|
||||
runBlocking {
|
||||
val v = metadata("only", 100)
|
||||
store.insert(v)
|
||||
|
||||
@@ -145,7 +151,8 @@ class FsSlotsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaceable slot survives canonical deletion via hardlink`() {
|
||||
fun `replaceable slot survives canonical deletion via hardlink`() =
|
||||
runBlocking {
|
||||
val v = metadata("x", 100)
|
||||
store.insert(v)
|
||||
|
||||
@@ -165,7 +172,8 @@ class FsSlotsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eviction unlinks index hardlinks for the old winner`() {
|
||||
fun `eviction unlinks index hardlinks for the old winner`() =
|
||||
runBlocking {
|
||||
val v1 = metadata("old", 100)
|
||||
val v2 = metadata("new", 200)
|
||||
store.insert(v1)
|
||||
@@ -182,7 +190,8 @@ class FsSlotsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slot shortcut serves replaceable queries even when idx is wiped`() {
|
||||
fun `slot shortcut serves replaceable queries even when idx is wiped`() =
|
||||
runBlocking {
|
||||
// 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
|
||||
@@ -203,7 +212,8 @@ class FsSlotsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `slot shortcut serves addressable queries when d-tag supplied`() {
|
||||
fun `slot shortcut serves addressable queries when d-tag supplied`() =
|
||||
runBlocking {
|
||||
val v = article("intro", "v", 10)
|
||||
store.insert(v)
|
||||
java.nio.file.Files
|
||||
@@ -227,7 +237,8 @@ class FsSlotsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete of current replaceable winner clears the slot`() {
|
||||
fun `delete of current replaceable winner clears the slot`() =
|
||||
runBlocking {
|
||||
val v = metadata("only", 100)
|
||||
store.insert(v)
|
||||
|
||||
@@ -255,7 +266,8 @@ class FsSlotsTest {
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `newer addressable evicts older for same d-tag`() {
|
||||
fun `newer addressable evicts older for same d-tag`() =
|
||||
runBlocking {
|
||||
val v1 = article("intro", "draft 1", 10)
|
||||
val v2 = article("intro", "draft 2", 20)
|
||||
store.insert(v1)
|
||||
@@ -267,7 +279,8 @@ class FsSlotsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addressable with different d-tags coexist`() {
|
||||
fun `addressable with different d-tags coexist`() =
|
||||
runBlocking {
|
||||
val intro = article("intro", "hello", 10)
|
||||
val about = article("about", "bio", 15)
|
||||
store.insert(intro)
|
||||
@@ -278,7 +291,8 @@ class FsSlotsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `older addressable is rejected when newer exists`() {
|
||||
fun `older addressable is rejected when newer exists`() =
|
||||
runBlocking {
|
||||
val newer = article("slug", "new", 200)
|
||||
val older = article("slug", "old", 100)
|
||||
store.insert(newer)
|
||||
@@ -289,7 +303,8 @@ class FsSlotsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addressable slot file contains the current winner`() {
|
||||
fun `addressable slot file contains the current winner`() =
|
||||
runBlocking {
|
||||
val v = article("intro", "hello", 10)
|
||||
store.insert(v)
|
||||
|
||||
@@ -301,7 +316,8 @@ class FsSlotsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty d-tag gets its own slot`() {
|
||||
fun `empty d-tag gets its own slot`() =
|
||||
runBlocking {
|
||||
val v = article("", "homepage", 1)
|
||||
store.insert(v)
|
||||
|
||||
@@ -311,7 +327,8 @@ class FsSlotsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete of current addressable winner clears the slot`() {
|
||||
fun `delete of current addressable winner clears the slot`() =
|
||||
runBlocking {
|
||||
val v = article("intro", "hello", 10)
|
||||
store.insert(v)
|
||||
val dHash = FsLayout.sha256Hex("intro")
|
||||
@@ -327,7 +344,8 @@ class FsSlotsTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `regular text note has no slot`() {
|
||||
fun `regular text note has no slot`() =
|
||||
runBlocking {
|
||||
val note =
|
||||
signer.sign<Event>(
|
||||
createdAt = 1,
|
||||
|
||||
+23
-11
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent
|
||||
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.exists
|
||||
@@ -81,7 +82,8 @@ class FsVanishTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@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`() =
|
||||
runBlocking {
|
||||
val n1 = note("a", 10)
|
||||
val n2 = note("b", 20)
|
||||
val n3 = note("c", 30) // same ts as vanish — survives because cascade uses strict <
|
||||
@@ -99,7 +101,8 @@ class FsVanishTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vanish for a different relay does NOT cascade`() {
|
||||
fun `vanish for a different relay does NOT cascade`() =
|
||||
runBlocking {
|
||||
val n = note("x", 10)
|
||||
store.insert(n)
|
||||
|
||||
@@ -117,7 +120,8 @@ class FsVanishTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vanishFromEverywhere always cascades regardless of relay`() {
|
||||
fun `vanishFromEverywhere always cascades regardless of relay`() =
|
||||
runBlocking {
|
||||
val n = note("x", 10)
|
||||
store.insert(n)
|
||||
|
||||
@@ -132,7 +136,8 @@ class FsVanishTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `events older than vanish are blocked from re-insertion`() {
|
||||
fun `events older than vanish are blocked from re-insertion`() =
|
||||
runBlocking {
|
||||
val n = note("a", 10)
|
||||
store.insert(n)
|
||||
|
||||
@@ -150,7 +155,8 @@ class FsVanishTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `events at vanish ts are blocked, parity with SQLite`() {
|
||||
fun `events at vanish ts are blocked, parity with SQLite`() =
|
||||
runBlocking {
|
||||
val v = vanish(ts = 50)
|
||||
store.insert(v)
|
||||
|
||||
@@ -160,7 +166,8 @@ class FsVanishTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `events newer than vanish still pass`() {
|
||||
fun `events newer than vanish still pass`() =
|
||||
runBlocking {
|
||||
val v = vanish(ts = 50)
|
||||
store.insert(v)
|
||||
|
||||
@@ -170,7 +177,8 @@ class FsVanishTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `another author is unaffected by my vanish`() {
|
||||
fun `another author is unaffected by my vanish`() =
|
||||
runBlocking {
|
||||
val mine = note("mine", 10)
|
||||
store.insert(mine)
|
||||
val theirs = note("theirs", 5, s = otherSigner)
|
||||
@@ -188,7 +196,8 @@ class FsVanishTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `later vanish raises the cutoff`() {
|
||||
fun `later vanish raises the cutoff`() =
|
||||
runBlocking {
|
||||
val n100 = note("at-100", 100)
|
||||
store.insert(n100)
|
||||
store.insert(vanish(ts = 50))
|
||||
@@ -206,7 +215,8 @@ class FsVanishTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `earlier vanish does not lower a stronger cutoff`() {
|
||||
fun `earlier vanish does not lower a stronger cutoff`() =
|
||||
runBlocking {
|
||||
store.insert(vanish(ts = 200))
|
||||
store.insert(vanish(ts = 50)) // older — should be a no-op for the tombstone
|
||||
|
||||
@@ -220,7 +230,8 @@ class FsVanishTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `vanish tombstone shares an inode with the kind-62 event`() {
|
||||
fun `vanish tombstone shares an inode with the kind-62 event`() =
|
||||
runBlocking {
|
||||
val v = vanish(ts = 30)
|
||||
store.insert(v)
|
||||
|
||||
@@ -239,7 +250,8 @@ class FsVanishTest {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `vanish event itself is indexed and queryable`() {
|
||||
fun `vanish event itself is indexed and queryable`() =
|
||||
runBlocking {
|
||||
val v = vanish(ts = 30)
|
||||
store.insert(v)
|
||||
|
||||
|
||||
+205
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user