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