Merge pull request #2567 from vitorpamplona/claude/review-quartz-sqlite-store-xBzaq
Implement NIP-01 lexical id tiebreaker and NIP-09 author-only deletion
This commit is contained in:
+13
-8
@@ -32,12 +32,14 @@ class AddressableModule : IModule {
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
// deletes older addressables when inserting new ones
|
||||
// if a newer addressable is inserted the unique index
|
||||
// above will be triggered. Delete cascade will take
|
||||
// care of the event_tags table
|
||||
// the duplicate: kind >= 30000 AND kind < 40000
|
||||
// helps SQLlite find the index above
|
||||
// deletes older addressables when inserting new ones.
|
||||
// Per NIP-01, the "older" version of an addressable is the one
|
||||
// with the smaller created_at, OR with equal created_at and
|
||||
// a lexicographically larger id (lowest id wins).
|
||||
// If a newer addressable is inserted the unique index above
|
||||
// will be triggered. Delete cascade will take care of the
|
||||
// event_tags table. The duplicate `kind >= 30000 AND kind < 40000`
|
||||
// clause helps SQLite pick the partial unique index above.
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER delete_older_addressable_event
|
||||
@@ -50,8 +52,11 @@ class AddressableModule : IModule {
|
||||
event_headers.kind = NEW.kind AND
|
||||
event_headers.pubkey = NEW.pubkey AND
|
||||
event_headers.d_tag = NEW.d_tag AND
|
||||
event_headers.created_at < NEW.created_at AND
|
||||
event_headers.kind >= 30000 AND event_headers.kind < 40000;
|
||||
event_headers.kind >= 30000 AND event_headers.kind < 40000 AND
|
||||
(
|
||||
event_headers.created_at < NEW.created_at OR
|
||||
(event_headers.created_at = NEW.created_at AND event_headers.id > NEW.id)
|
||||
);
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
+12
-6
@@ -87,7 +87,7 @@ class DeletionRequestModule(
|
||||
val idValues = event.deleteEventIds()
|
||||
val addresses = event.deleteAddresses()
|
||||
|
||||
deleteSQL(event.pubKey, idValues, addresses, hasher(db)).forEach { delete ->
|
||||
deleteSQL(event.pubKey, event.createdAt, idValues, addresses, hasher(db)).forEach { delete ->
|
||||
db.execSQL(delete.sql, delete.args)
|
||||
}
|
||||
}
|
||||
@@ -97,10 +97,14 @@ class DeletionRequestModule(
|
||||
* Creates a Delete statement that correctly deletes by id,
|
||||
* by address and by replaceable (no d-tag) using each index
|
||||
* appropriately, including GiftWraps where the owner is the
|
||||
* p-tag (via event_header.pubkey_owner_hash)
|
||||
* p-tag (via event_header.pubkey_owner_hash).
|
||||
*
|
||||
* Per NIP-09, address-based deletes (a-tag) MUST only remove
|
||||
* events with `created_at <= deletion.created_at`.
|
||||
*/
|
||||
fun deleteSQL(
|
||||
pubkey: HexKey,
|
||||
createdAt: Long,
|
||||
idValues: List<String>,
|
||||
addresses: List<Address>,
|
||||
hasher: com.vitorpamplona.quartz.nip01Core.store.sqlite.TagNameValueHasher,
|
||||
@@ -156,9 +160,10 @@ class DeletionRequestModule(
|
||||
WHERE (
|
||||
$addressableParams
|
||||
) AND
|
||||
kind >= 30000 AND kind < 40000
|
||||
kind >= 30000 AND kind < 40000 AND
|
||||
+created_at <= ?
|
||||
""".trimIndent(),
|
||||
addressableValues.toTypedArray(),
|
||||
(addressableValues + createdAt).toTypedArray(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -172,9 +177,10 @@ class DeletionRequestModule(
|
||||
WHERE
|
||||
kind IN ($replaceableKindsParam) AND
|
||||
pubkey = ? AND
|
||||
((kind in (0,3)) OR (kind >= 10000 AND kind < 20000))
|
||||
((kind in (0,3)) OR (kind >= 10000 AND kind < 20000)) AND
|
||||
created_at <= ?
|
||||
""".trimIndent(),
|
||||
replaceableKindsValues.plus(pubkey).toTypedArray(),
|
||||
(replaceableKindsValues.plus(pubkey) + createdAt).toTypedArray(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
||||
+26
-11
@@ -31,7 +31,7 @@ class FullTextSearchModule : IModule {
|
||||
val contentName = "content"
|
||||
|
||||
override fun create(db: SQLiteConnection) {
|
||||
val ftsVersion = FullTextSearchModule().versionFinder(db)
|
||||
val ftsVersion = versionFinder(db)
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE VIRTUAL TABLE $tableName
|
||||
@@ -77,19 +77,34 @@ class FullTextSearchModule : IModule {
|
||||
}
|
||||
}
|
||||
|
||||
fun versionFinder(db: SQLiteConnection): Int =
|
||||
try {
|
||||
fun versionFinder(db: SQLiteConnection): Int {
|
||||
// Defensive cleanup in case a previous probe left these behind
|
||||
// (e.g. a partial create() during an upgrade) — without this,
|
||||
// the next CREATE VIRTUAL TABLE would fail with "already exists".
|
||||
db.execSQL("DROP TABLE IF EXISTS dummy_fts5")
|
||||
db.execSQL("DROP TABLE IF EXISTS dummy_fts4")
|
||||
db.execSQL("DROP TABLE IF EXISTS dummy_fts3")
|
||||
|
||||
val (table, version) =
|
||||
try {
|
||||
db.execSQL("CREATE VIRTUAL TABLE dummy_fts5 USING fts5(dummy)")
|
||||
5
|
||||
try {
|
||||
db.execSQL("CREATE VIRTUAL TABLE dummy_fts5 USING fts5(dummy)")
|
||||
"dummy_fts5" to 5
|
||||
} catch (e: SQLiteException) {
|
||||
db.execSQL("CREATE VIRTUAL TABLE dummy_fts4 USING fts4(dummy)")
|
||||
"dummy_fts4" to 4
|
||||
}
|
||||
} catch (e: SQLiteException) {
|
||||
db.execSQL("CREATE VIRTUAL TABLE dummy_fts4 USING fts4(dummy)")
|
||||
4
|
||||
db.execSQL("CREATE VIRTUAL TABLE dummy_fts3 USING fts3(dummy)")
|
||||
"dummy_fts3" to 3
|
||||
}
|
||||
} catch (e: SQLiteException) {
|
||||
db.execSQL("CREATE VIRTUAL TABLE dummy_fts3 USING fts3(dummy)")
|
||||
3
|
||||
}
|
||||
|
||||
// We only needed the table to probe FTS support — drop it now so
|
||||
// re-running create() on an already-probed DB stays idempotent.
|
||||
db.execSQL("DROP TABLE $table")
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
override fun deleteAll(db: SQLiteConnection) {
|
||||
db.execSQL("DELETE FROM event_fts")
|
||||
|
||||
+8
@@ -318,6 +318,13 @@ class QueryBuilder(
|
||||
// --------------
|
||||
// Deletes
|
||||
// -------------
|
||||
|
||||
/**
|
||||
* Safe-by-default: an empty filter (or a list of only empty filters)
|
||||
* deletes nothing and returns 0, so a stray `delete(Filter())` cannot
|
||||
* wipe the entire store. This is asymmetric with `query(Filter())`,
|
||||
* which intentionally returns every event.
|
||||
*/
|
||||
fun delete(
|
||||
filter: Filter,
|
||||
db: SQLiteConnection,
|
||||
@@ -331,6 +338,7 @@ class QueryBuilder(
|
||||
}
|
||||
}
|
||||
|
||||
/** See [delete] for the empty-filter contract. */
|
||||
fun delete(
|
||||
filters: List<Filter>,
|
||||
db: SQLiteConnection,
|
||||
|
||||
+4
-1
@@ -14,11 +14,13 @@ consistency can be sacrificed, but a database that will never crash and never go
|
||||
- Forces unique constraint by kind, pubkey
|
||||
- Old versions are removed when newer versions arrive.
|
||||
- Old versions are blocked if newer versions exist.
|
||||
- Same `created_at`: NIP-01 lexical-id tiebreaker (lowest id wins).
|
||||
|
||||
- **Addressable Events**:
|
||||
- Forces unique constraint by kind, pubkey, d-tag
|
||||
- Old versions are removed when newer versions arrive.
|
||||
- Old versions are blocked if newer versions exist.
|
||||
- Same `created_at`: NIP-01 lexical-id tiebreaker (lowest id wins).
|
||||
|
||||
- **Ephemeral Events**
|
||||
- Ephemeral events never stored.
|
||||
@@ -29,8 +31,9 @@ consistency can be sacrificed, but a database that will never crash and never go
|
||||
|
||||
- **NIP-09 Deletion Events**
|
||||
- Deletes by event id
|
||||
- Deletes by address until the `created_at`
|
||||
- Deletes by address up to and including the deletion's `created_at` (newer versions are kept).
|
||||
- Blocks deleted events from being re-inserted.
|
||||
- Only the original author's deletions take effect; cross-author kind-5s are stored but inert.
|
||||
- GiftWraps are deleted by p-tag
|
||||
|
||||
- **NIP-62 Right to Vanish**
|
||||
|
||||
+11
-6
@@ -32,10 +32,13 @@ class ReplaceableModule : IModule {
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
// deletes older addressables when inserting new ones
|
||||
// if a newer addressable is inserted the unique index
|
||||
// above will be triggered. Delete cascade will take
|
||||
// care of the event_tags table
|
||||
// deletes older replaceables when inserting new ones.
|
||||
// Per NIP-01, the "older" version of a replaceable is the one
|
||||
// with the smaller created_at, OR with equal created_at and
|
||||
// a lexicographically larger id (lowest id wins).
|
||||
// If a newer replaceable is inserted the unique index above
|
||||
// will be triggered. Delete cascade will take care of the
|
||||
// event_tags table.
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER delete_older_replaceable_event
|
||||
@@ -43,12 +46,14 @@ class ReplaceableModule : IModule {
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.kind IN (0, 3)) OR (NEW.kind >= 10000 AND NEW.kind < 20000)
|
||||
BEGIN
|
||||
-- Delete older records if this is the newest
|
||||
DELETE FROM event_headers
|
||||
WHERE
|
||||
event_headers.kind = NEW.kind AND
|
||||
event_headers.pubkey = NEW.pubkey AND
|
||||
event_headers.created_at < NEW.created_at;
|
||||
(
|
||||
event_headers.created_at < NEW.created_at OR
|
||||
(event_headers.created_at = NEW.created_at AND event_headers.id > NEW.id)
|
||||
);
|
||||
END;
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
+14
-4
@@ -70,14 +70,24 @@ fun SQLiteConnection.changes(): Int =
|
||||
|
||||
inline fun <T> SQLiteConnection.transaction(body: SQLiteConnection.() -> T): T {
|
||||
execSQL("BEGIN IMMEDIATE TRANSACTION")
|
||||
val result: T
|
||||
try {
|
||||
val result = body()
|
||||
execSQL("END TRANSACTION")
|
||||
return result
|
||||
result = body()
|
||||
} catch (e: Throwable) {
|
||||
execSQL("ROLLBACK TRANSACTION")
|
||||
// Attempt rollback, but never let a rollback failure mask the
|
||||
// original cause — attach it as a suppressed exception instead.
|
||||
try {
|
||||
execSQL("ROLLBACK TRANSACTION")
|
||||
} catch (rollbackError: Throwable) {
|
||||
e.addSuppressed(rollbackError)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
// Commit is intentionally outside the catch: if COMMIT fails SQLite
|
||||
// has already finalized the transaction state, so we must not also
|
||||
// try to ROLLBACK on top of it.
|
||||
execSQL("END TRANSACTION")
|
||||
return result
|
||||
}
|
||||
|
||||
fun SQLiteStatement.bindAny(
|
||||
|
||||
+12
-8
@@ -108,11 +108,15 @@ class SQLiteEventStore(
|
||||
|
||||
val currentVersion = getUserVersion(db)
|
||||
if (currentVersion == 0) {
|
||||
onCreate(db)
|
||||
setUserVersion(db, DATABASE_VERSION)
|
||||
db.transaction {
|
||||
onCreate(this)
|
||||
setUserVersion(this, DATABASE_VERSION)
|
||||
}
|
||||
} else if (currentVersion < DATABASE_VERSION) {
|
||||
onUpgrade(db, currentVersion, DATABASE_VERSION)
|
||||
setUserVersion(db, DATABASE_VERSION)
|
||||
db.transaction {
|
||||
onUpgrade(this, currentVersion, DATABASE_VERSION)
|
||||
setUserVersion(this, DATABASE_VERSION)
|
||||
}
|
||||
}
|
||||
|
||||
return db
|
||||
@@ -160,16 +164,16 @@ class SQLiteEventStore(
|
||||
}
|
||||
|
||||
suspend fun vacuum() {
|
||||
// 1. ANALYZE: Collects statistics about tables and indices
|
||||
// to help the query planner optimize queries.
|
||||
// VACUUM: Rebuilds the database file, reclaiming unused space
|
||||
// and reducing fragmentation.
|
||||
withContext(Dispatchers.IO) {
|
||||
connection.execSQL("VACUUM")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun analyse() {
|
||||
// 2. VACUUM: Rebuilds the database file, reclaiming unused space
|
||||
// and reducing fragmentation.
|
||||
// ANALYZE: Collects statistics about tables and indices
|
||||
// to help the query planner optimize queries.
|
||||
withContext(Dispatchers.IO) {
|
||||
connection.execSQL("ANALYZE")
|
||||
}
|
||||
|
||||
+14
-2
@@ -22,7 +22,10 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import kotlin.concurrent.atomics.AtomicReference
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
class SeedModule : IModule {
|
||||
override fun create(db: SQLiteConnection) {
|
||||
db.execSQL("CREATE TABLE seeds (seed_value INTEGER)")
|
||||
@@ -78,7 +81,16 @@ class SeedModule : IModule {
|
||||
|
||||
override fun deleteAll(db: SQLiteConnection) {}
|
||||
|
||||
private var hasherCache: TagNameValueHasher? = null
|
||||
// The hasher is keyed off a stable per-DB seed, so two concurrent
|
||||
// computations would produce identical hashers — the race is benign.
|
||||
// We still publish via AtomicReference so the field write is visible
|
||||
// across threads (commonMain has no @Volatile equivalent).
|
||||
private val hasherCache = AtomicReference<TagNameValueHasher?>(null)
|
||||
|
||||
fun hasher(db: SQLiteConnection): TagNameValueHasher = hasherCache ?: TagNameValueHasher(getSeed(db)).also { hasherCache = it }
|
||||
fun hasher(db: SQLiteConnection): TagNameValueHasher {
|
||||
hasherCache.load()?.let { return it }
|
||||
val fresh = TagNameValueHasher(getSeed(db))
|
||||
hasherCache.compareAndSet(null, fresh)
|
||||
return hasherCache.load() ?: fresh
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ class SqlSelectionBuilder(
|
||||
|
||||
is Condition.NotEquals -> {
|
||||
if (cond.value == null) {
|
||||
"${cond.column} IS NULL"
|
||||
"${cond.column} IS NOT NULL"
|
||||
} else {
|
||||
selectionArgs.add(cond.value.toString())
|
||||
"${cond.column} != ?"
|
||||
|
||||
+2
-2
@@ -27,10 +27,10 @@ fun TagArray.expiration() = this.firstNotNullOfOrNull(ExpirationTag::parse)
|
||||
|
||||
fun TagArray.isExpired(): Boolean {
|
||||
val exp = expiration() ?: return false
|
||||
return exp < TimeUtils.now()
|
||||
return exp <= TimeUtils.now()
|
||||
}
|
||||
|
||||
fun TagArray.isExpirationBefore(time: Long): Boolean {
|
||||
val exp = expiration() ?: return false
|
||||
return exp < time
|
||||
return exp <= time
|
||||
}
|
||||
|
||||
+66
@@ -89,6 +89,72 @@ class AddressableTest : BaseDBTest() {
|
||||
db.assertQuery(null, Filter(ids = listOf(version1.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReplacingSameCreatedAtLowerIdWins() =
|
||||
forEachDB { db ->
|
||||
val time = TimeUtils.now()
|
||||
// Two events with the same (kind, pubkey, d_tag, created_at) but
|
||||
// different content -> different ids. NIP-01 says the lower-id wins.
|
||||
val a =
|
||||
signer.sign(
|
||||
LongTextNoteEvent.build(
|
||||
"version A",
|
||||
"title",
|
||||
dTag = "tie",
|
||||
createdAt = time,
|
||||
),
|
||||
)
|
||||
val b =
|
||||
signer.sign(
|
||||
LongTextNoteEvent.build(
|
||||
"version B",
|
||||
"title",
|
||||
dTag = "tie",
|
||||
createdAt = time,
|
||||
),
|
||||
)
|
||||
val (winner, loser) = if (a.id < b.id) a to b else b to a
|
||||
|
||||
db.insert(loser)
|
||||
db.assertQuery(loser, Filter(ids = listOf(loser.id)))
|
||||
|
||||
db.insert(winner)
|
||||
db.assertQuery(null, Filter(ids = listOf(loser.id)))
|
||||
db.assertQuery(winner, Filter(ids = listOf(winner.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReplacingSameCreatedAtHigherIdRejected() =
|
||||
forEachDB { db ->
|
||||
val time = TimeUtils.now()
|
||||
val a =
|
||||
signer.sign(
|
||||
LongTextNoteEvent.build(
|
||||
"version A",
|
||||
"title",
|
||||
dTag = "tie",
|
||||
createdAt = time,
|
||||
),
|
||||
)
|
||||
val b =
|
||||
signer.sign(
|
||||
LongTextNoteEvent.build(
|
||||
"version B",
|
||||
"title",
|
||||
dTag = "tie",
|
||||
createdAt = time,
|
||||
),
|
||||
)
|
||||
val (winner, loser) = if (a.id < b.id) a to b else b to a
|
||||
|
||||
db.insert(winner)
|
||||
assertFailsWith<SQLiteException> {
|
||||
db.insert(loser)
|
||||
}
|
||||
db.assertQuery(winner, Filter(ids = listOf(winner.id)))
|
||||
db.assertQuery(null, Filter(ids = listOf(loser.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTriggersIndexUsage() =
|
||||
forEachDB { db ->
|
||||
|
||||
+118
@@ -20,16 +20,19 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import androidx.sqlite.SQLiteException
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BasicTest : BaseDBTest() {
|
||||
@@ -200,6 +203,121 @@ class BasicTest : BaseDBTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeleteWithEmptyFilterIsSafe() =
|
||||
forEachDB { db ->
|
||||
val note1 = signer.sign(TextNoteEvent.build("test1", createdAt = 1))
|
||||
val note2 = signer.sign(TextNoteEvent.build("test2", createdAt = 2))
|
||||
|
||||
db.insert(note1)
|
||||
db.insert(note2)
|
||||
|
||||
// Empty filter: query returns everything, but delete must NOT
|
||||
// wipe the store. This asymmetry is intentional safe-by-default.
|
||||
assertEquals(2, db.count(Filter()))
|
||||
db.delete(Filter())
|
||||
|
||||
db.assertQuery(note1, Filter(ids = listOf(note1.id)))
|
||||
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
|
||||
|
||||
// Same applies to a list of empty filters.
|
||||
db.delete(listOf(Filter(), Filter()))
|
||||
assertEquals(2, db.count(Filter()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTransactionRollsBackOnException() =
|
||||
forEachDB { db ->
|
||||
val note1 = signer.sign(TextNoteEvent.build("kept", createdAt = 1))
|
||||
val note2 = signer.sign(TextNoteEvent.build("rolled-back", createdAt = 2))
|
||||
|
||||
// Pre-existing event the test should not disturb.
|
||||
db.insert(note1)
|
||||
db.assertQuery(note1, Filter(ids = listOf(note1.id)))
|
||||
|
||||
// A user-level transaction that inserts note2 then throws should
|
||||
// leave the DB exactly as it was before — note2 must NOT remain.
|
||||
val sentinel = RuntimeException("boom")
|
||||
try {
|
||||
db.transaction {
|
||||
insert(note2)
|
||||
throw sentinel
|
||||
}
|
||||
error("transaction should have rethrown")
|
||||
} catch (e: RuntimeException) {
|
||||
kotlin.test.assertEquals(sentinel, e)
|
||||
}
|
||||
|
||||
db.assertQuery(note1, Filter(ids = listOf(note1.id)))
|
||||
db.assertQuery(null, Filter(ids = listOf(note2.id)))
|
||||
|
||||
// After a rollback, the DB must still accept new writes.
|
||||
db.insert(note2)
|
||||
db.assertQuery(note2, Filter(ids = listOf(note2.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTransactionRollsBackOnTriggerAbort() =
|
||||
forEachDB { db ->
|
||||
// Pre-load a target event and a deletion that blocks re-insertion.
|
||||
val deletedTarget = signer.sign(TextNoteEvent.build("target", createdAt = 1))
|
||||
val deletion = signer.sign(DeletionEvent.build(listOf(deletedTarget), createdAt = 100))
|
||||
db.insert(deletion)
|
||||
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
|
||||
|
||||
val keptInThisTx = signer.sign(TextNoteEvent.build("kept-in-tx", createdAt = 2))
|
||||
|
||||
// A trigger ABORT in the middle of a multi-insert transaction must
|
||||
// roll BOTH inserts back — `reject_deleted_events` only undoes its
|
||||
// own statement, but the wrapping `connection.transaction` extension
|
||||
// is responsible for ROLLBACK-ing the whole batch.
|
||||
assertFailsWith<SQLiteException> {
|
||||
db.transaction {
|
||||
insert(keptInThisTx)
|
||||
insert(deletedTarget) // blocked by reject_deleted_events
|
||||
}
|
||||
}
|
||||
|
||||
db.assertQuery(null, Filter(ids = listOf(keptInThisTx.id)))
|
||||
db.assertQuery(null, Filter(ids = listOf(deletedTarget.id)))
|
||||
// The deletion stored before the failed transaction must remain.
|
||||
db.assertQuery(deletion, Filter(ids = listOf(deletion.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testVacuumAndAnalyseSmoke() =
|
||||
forEachDB { db ->
|
||||
// Smoke test: VACUUM and ANALYZE must not throw on a populated DB
|
||||
// and must leave existing rows intact. Also catches the comments
|
||||
// for these two functions getting swapped again.
|
||||
val note = signer.sign(TextNoteEvent.build("vacuum me"))
|
||||
db.insert(note)
|
||||
|
||||
db.store.analyse()
|
||||
db.store.vacuum()
|
||||
|
||||
db.assertQuery(note, Filter(ids = listOf(note.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSchemaRecreateIsIdempotent() =
|
||||
forEachDB { db ->
|
||||
// The v1->v2 upgrade in SQLiteEventStore.onUpgrade does
|
||||
// modules.reversed().forEach { it.drop(db) } then
|
||||
// modules.forEach { it.create(db) }. Pre-fix, FullTextSearchModule
|
||||
// left dummy_fts3/4/5 tables behind on first probe, so the
|
||||
// second create() would throw "already exists".
|
||||
db.store.modules
|
||||
.reversed()
|
||||
.forEach { it.drop(db.store.connection) }
|
||||
db.store.modules.forEach { it.create(db.store.connection) }
|
||||
|
||||
// After re-creation the store is still usable.
|
||||
val note = signer.sign(TextNoteEvent.build("test1"))
|
||||
db.store.insertEvent(note)
|
||||
db.store.assertQuery(note, Filter(ids = listOf(note.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hashCodeTest() =
|
||||
forEachDB { db ->
|
||||
|
||||
+161
-11
@@ -93,7 +93,9 @@ class DeletionTest : BaseDBTest() {
|
||||
db.assertQuery(null, Filter(ids = listOf(note2.id)))
|
||||
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
|
||||
|
||||
val deletion = signer.sign(DeletionEvent.build(listOf(note1)))
|
||||
// Deletion must be timestamped >= note3.createdAt for it to
|
||||
// remove the latest addressable per NIP-09.
|
||||
val deletion = signer.sign(DeletionEvent.build(listOf(note1), createdAt = time + 100))
|
||||
|
||||
db.insert(deletion)
|
||||
|
||||
@@ -128,7 +130,7 @@ class DeletionTest : BaseDBTest() {
|
||||
db.assertQuery(null, Filter(ids = listOf(note2.id)))
|
||||
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
|
||||
|
||||
val deletion = signer.sign(DeletionEvent.buildAddressOnly(listOf(note1)))
|
||||
val deletion = signer.sign(DeletionEvent.buildAddressOnly(listOf(note1), createdAt = time + 100))
|
||||
|
||||
db.insert(deletion)
|
||||
|
||||
@@ -226,6 +228,7 @@ class DeletionTest : BaseDBTest() {
|
||||
db.store.deletionModule
|
||||
.deleteSQL(
|
||||
pubkey = "key1",
|
||||
createdAt = 1766686500,
|
||||
idValues = listOf("ca29c211f", "ca29c211d"),
|
||||
addresses = emptyList(),
|
||||
hasher =
|
||||
@@ -256,6 +259,7 @@ class DeletionTest : BaseDBTest() {
|
||||
db.store.deletionModule
|
||||
.deleteSQL(
|
||||
pubkey = "key1",
|
||||
createdAt = 1766686500,
|
||||
idValues = emptyList(),
|
||||
addresses =
|
||||
listOf(
|
||||
@@ -273,8 +277,9 @@ class DeletionTest : BaseDBTest() {
|
||||
WHERE (
|
||||
(kind = "30000" AND pubkey = "key1" AND d_tag = "a")
|
||||
) AND
|
||||
kind >= 30000 AND kind < 40000
|
||||
├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
|
||||
kind >= 30000 AND kind < 40000 AND
|
||||
+created_at <= "1766686500"
|
||||
├── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
|
||||
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
|
||||
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
|
||||
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
|
||||
@@ -290,6 +295,7 @@ class DeletionTest : BaseDBTest() {
|
||||
db.store.deletionModule
|
||||
.deleteSQL(
|
||||
pubkey = "key1",
|
||||
createdAt = 1766686500,
|
||||
idValues = emptyList(),
|
||||
addresses =
|
||||
listOf(
|
||||
@@ -310,8 +316,9 @@ class DeletionTest : BaseDBTest() {
|
||||
WHERE (
|
||||
(kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b","c","d"))
|
||||
) AND
|
||||
kind >= 30000 AND kind < 40000
|
||||
├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
|
||||
kind >= 30000 AND kind < 40000 AND
|
||||
+created_at <= "1766686500"
|
||||
├── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
|
||||
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
|
||||
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
|
||||
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
|
||||
@@ -327,6 +334,7 @@ class DeletionTest : BaseDBTest() {
|
||||
db.store.deletionModule
|
||||
.deleteSQL(
|
||||
pubkey = "key1",
|
||||
createdAt = 1766686500,
|
||||
idValues = emptyList(),
|
||||
addresses =
|
||||
listOf(
|
||||
@@ -351,12 +359,13 @@ class DeletionTest : BaseDBTest() {
|
||||
OR
|
||||
(kind = "30101" AND pubkey = "key1" AND d_tag IN ("c","d"))
|
||||
) AND
|
||||
kind >= 30000 AND kind < 40000
|
||||
kind >= 30000 AND kind < 40000 AND
|
||||
+created_at <= "1766686500"
|
||||
├── MULTI-INDEX OR
|
||||
│ ├── INDEX 1
|
||||
│ │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
|
||||
│ │ └── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
|
||||
│ └── INDEX 2
|
||||
│ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
|
||||
│ └── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?)
|
||||
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
|
||||
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
|
||||
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
|
||||
@@ -372,6 +381,7 @@ class DeletionTest : BaseDBTest() {
|
||||
db.store.deletionModule
|
||||
.deleteSQL(
|
||||
pubkey = "key1",
|
||||
createdAt = 1766686500,
|
||||
idValues = emptyList(),
|
||||
addresses =
|
||||
listOf(
|
||||
@@ -394,8 +404,9 @@ class DeletionTest : BaseDBTest() {
|
||||
WHERE
|
||||
kind IN ("10000","10001") AND
|
||||
pubkey = "key1" AND
|
||||
((kind in (0,3)) OR (kind >= 10000 AND kind < 20000))
|
||||
├── SEARCH event_headers USING COVERING INDEX replaceable_idx (kind=? AND pubkey=?)
|
||||
((kind in (0,3)) OR (kind >= 10000 AND kind < 20000)) AND
|
||||
created_at <= "1766686500"
|
||||
├── SEARCH event_headers USING COVERING INDEX query_by_kind_pubkey_created (kind=? AND pubkey=? AND created_at<?)
|
||||
├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?)
|
||||
├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?)
|
||||
└── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?)
|
||||
@@ -403,4 +414,143 @@ class DeletionTest : BaseDBTest() {
|
||||
db.store.explainQuery(sql.sql, sql.args),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeletionByThirdPartyDoesNothing() =
|
||||
forEachDB { db ->
|
||||
val owner = NostrSignerSync()
|
||||
val stranger = NostrSignerSync()
|
||||
|
||||
val target = owner.sign(TextNoteEvent.build("private"))
|
||||
db.insert(target)
|
||||
db.assertQuery(target, Filter(ids = listOf(target.id)))
|
||||
|
||||
// A deletion event from a *different* author must not remove the
|
||||
// target — only the original author can delete via NIP-09.
|
||||
val strangerDeletion = stranger.sign(DeletionEvent.build(listOf(target)))
|
||||
db.insert(strangerDeletion)
|
||||
|
||||
db.assertQuery(target, Filter(ids = listOf(target.id)))
|
||||
db.assertQuery(strangerDeletion, Filter(ids = listOf(strangerDeletion.id)))
|
||||
|
||||
// The owner's deletion still works after a failed third-party attempt.
|
||||
val ownerDeletion = owner.sign(DeletionEvent.build(listOf(target)))
|
||||
db.insert(ownerDeletion)
|
||||
db.assertQuery(null, Filter(ids = listOf(target.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testKind5CanBeDeletedByAnotherKind5OfSameAuthor() =
|
||||
forEachDB { db ->
|
||||
// Pinning current behavior: a kind-5 event is treated like any
|
||||
// other event for ownership purposes — the same author can issue
|
||||
// a second deletion that removes the first. Cross-author
|
||||
// deletion of a kind-5 must NOT work.
|
||||
val target = signer.sign(TextNoteEvent.build("target"))
|
||||
val firstDeletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = 100))
|
||||
|
||||
db.insert(target)
|
||||
db.insert(firstDeletion)
|
||||
db.assertQuery(null, Filter(ids = listOf(target.id)))
|
||||
db.assertQuery(firstDeletion, Filter(ids = listOf(firstDeletion.id)))
|
||||
|
||||
// Stranger cannot delete the kind-5.
|
||||
val stranger = NostrSignerSync()
|
||||
val strangerDeletion =
|
||||
stranger.sign(DeletionEvent.build(listOf(firstDeletion), createdAt = 200))
|
||||
db.insert(strangerDeletion)
|
||||
db.assertQuery(firstDeletion, Filter(ids = listOf(firstDeletion.id)))
|
||||
|
||||
// Same author can delete their previous kind-5.
|
||||
val secondDeletion =
|
||||
signer.sign(DeletionEvent.build(listOf(firstDeletion), createdAt = 300))
|
||||
db.insert(secondDeletion)
|
||||
db.assertQuery(null, Filter(ids = listOf(firstDeletion.id)))
|
||||
db.assertQuery(secondDeletion, Filter(ids = listOf(secondDeletion.id)))
|
||||
|
||||
// First deletion now blocked from re-insertion (own e-tag in event_tags).
|
||||
assertFailsWith<SQLiteException> {
|
||||
db.insert(firstDeletion)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGiftWrapDeletionRequiresRecipient() =
|
||||
forEachDB { db ->
|
||||
// GiftWraps key their owner off the p-tag (recipient), since the
|
||||
// outer wrap is signed by an ephemeral random key per NIP-59. The
|
||||
// inner author isn't visible to the relay, so only the recipient
|
||||
// can delete the wrap.
|
||||
val recipient = NostrSignerSync()
|
||||
val sender = NostrSignerSync()
|
||||
val unrelated = NostrSignerSync()
|
||||
|
||||
val inner = sender.sign(TextNoteEvent.build("secret"))
|
||||
val wrap = GiftWrapEvent.create(inner, recipient.pubKey)
|
||||
|
||||
db.insert(wrap)
|
||||
db.assertQuery(wrap, Filter(ids = listOf(wrap.id)))
|
||||
|
||||
// Inner author (the "sender") cannot delete a wrap addressed to
|
||||
// someone else — sender pubkey != wrap's pubkey_owner_hash.
|
||||
val senderDeletion = sender.sign(DeletionEvent.build(listOf(wrap)))
|
||||
db.insert(senderDeletion)
|
||||
db.assertQuery(wrap, Filter(ids = listOf(wrap.id)))
|
||||
|
||||
// An unrelated third party can't delete it either.
|
||||
val unrelatedDeletion = unrelated.sign(DeletionEvent.build(listOf(wrap)))
|
||||
db.insert(unrelatedDeletion)
|
||||
db.assertQuery(wrap, Filter(ids = listOf(wrap.id)))
|
||||
|
||||
// The recipient (whose pubkey matches pubkey_owner_hash) can.
|
||||
val recipientDeletion = recipient.sign(DeletionEvent.build(listOf(wrap)))
|
||||
db.insert(recipientDeletion)
|
||||
db.assertQuery(null, Filter(ids = listOf(wrap.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDeletionDoesNotRemoveNewerAddressable() =
|
||||
forEachDB { db ->
|
||||
// Old addressable (will be deleted by deletion request)
|
||||
val old =
|
||||
signer.sign(
|
||||
LongTextNoteEvent.build(
|
||||
"version 1",
|
||||
"title",
|
||||
dTag = "blog-1",
|
||||
createdAt = 1000,
|
||||
),
|
||||
)
|
||||
|
||||
db.insert(old)
|
||||
db.assertQuery(old, Filter(ids = listOf(old.id)))
|
||||
|
||||
// Deletion targeting the address with created_at = 1500
|
||||
val deletion =
|
||||
signer.sign(
|
||||
DeletionEvent.buildAddressOnly(
|
||||
listOf(old),
|
||||
createdAt = 1500,
|
||||
),
|
||||
)
|
||||
db.insert(deletion)
|
||||
|
||||
// The old version is gone
|
||||
db.assertQuery(null, Filter(ids = listOf(old.id)))
|
||||
|
||||
// Now a NEWER version of the same address arrives, with
|
||||
// created_at AFTER the deletion. NIP-09 says it must be kept.
|
||||
val newer =
|
||||
signer.sign(
|
||||
LongTextNoteEvent.build(
|
||||
"version 2",
|
||||
"title",
|
||||
dTag = "blog-1",
|
||||
createdAt = 2000,
|
||||
),
|
||||
)
|
||||
|
||||
db.insert(newer)
|
||||
db.assertQuery(newer, Filter(ids = listOf(newer.id)))
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -82,4 +82,26 @@ class ExpirationTest : BaseDBTest() {
|
||||
db.insert(note1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInsertingEventExpiringExactlyNow() =
|
||||
forEachDB { db ->
|
||||
val time = TimeUtils.now()
|
||||
|
||||
// Per NIP-40 the event is expired once `expiration` is reached;
|
||||
// the SQL trigger uses `<= unixepoch()` and Kotlin's isExpired
|
||||
// now agrees, so an event whose expiration equals "now" must be
|
||||
// rejected by both layers — not silently let through the Kotlin
|
||||
// pre-check only to fail in the trigger.
|
||||
val note =
|
||||
signer.sign(
|
||||
TextNoteEvent.build("expires-now", createdAt = time - 1) {
|
||||
expiration(time)
|
||||
},
|
||||
)
|
||||
|
||||
assertFailsWith<SQLiteException> {
|
||||
db.insert(note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -89,6 +89,43 @@ class ReplaceableTest : BaseDBTest() {
|
||||
db.assertQuery(null, Filter(ids = listOf(version1.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReplacingSameCreatedAtLowerIdWins() =
|
||||
forEachDB { db ->
|
||||
val time = TimeUtils.now()
|
||||
// Two events with the same (kind, pubkey, created_at) but different
|
||||
// content -> different ids. NIP-01 says the lower-id wins.
|
||||
val a = signer.sign(MetadataEvent.createNew("Vitor A", createdAt = time))
|
||||
val b = signer.sign(MetadataEvent.createNew("Vitor B", createdAt = time))
|
||||
val (winner, loser) = if (a.id < b.id) a to b else b to a
|
||||
|
||||
// Insert the higher-id (loser) first, then the lower-id (winner).
|
||||
// The trigger should delete the loser and let the winner in.
|
||||
db.insert(loser)
|
||||
db.assertQuery(loser, Filter(ids = listOf(loser.id)))
|
||||
|
||||
db.insert(winner)
|
||||
db.assertQuery(null, Filter(ids = listOf(loser.id)))
|
||||
db.assertQuery(winner, Filter(ids = listOf(winner.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testReplacingSameCreatedAtHigherIdRejected() =
|
||||
forEachDB { db ->
|
||||
val time = TimeUtils.now()
|
||||
val a = signer.sign(MetadataEvent.createNew("Vitor A", createdAt = time))
|
||||
val b = signer.sign(MetadataEvent.createNew("Vitor B", createdAt = time))
|
||||
val (winner, loser) = if (a.id < b.id) a to b else b to a
|
||||
|
||||
// Winner already in DB; the higher-id duplicate must be rejected.
|
||||
db.insert(winner)
|
||||
assertFailsWith<SQLiteException> {
|
||||
db.insert(loser)
|
||||
}
|
||||
db.assertQuery(winner, Filter(ids = listOf(winner.id)))
|
||||
db.assertQuery(null, Filter(ids = listOf(loser.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTriggersIndexUsageKind0() =
|
||||
forEachDB { db ->
|
||||
|
||||
+31
@@ -69,6 +69,37 @@ class RightToVanishTest : BaseDBTest() {
|
||||
db.assertQuery(note3, Filter(ids = listOf(note3.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testVanishForDifferentRelayIsNoOp() =
|
||||
forEachDB { db ->
|
||||
// The default EventStore is bound to wss://quartz.local. A vanish
|
||||
// request that names a *different* relay must not delete the
|
||||
// user's events on this relay — RightToVanishModule.insert checks
|
||||
// shouldVanishFrom(relay) and returns without writing event_vanish.
|
||||
val time = TimeUtils.now()
|
||||
val note = signer.sign(TextNoteEvent.build("kept", createdAt = time))
|
||||
db.insert(note)
|
||||
db.assertQuery(note, Filter(ids = listOf(note.id)))
|
||||
|
||||
val vanishElsewhere =
|
||||
signer.sign(
|
||||
RequestToVanishEvent.build(
|
||||
"wss://some-other-relay.example".normalizeRelayUrl(),
|
||||
createdAt = time + 1,
|
||||
),
|
||||
)
|
||||
db.insert(vanishElsewhere)
|
||||
|
||||
// The vanish event itself is stored, but it must not delete
|
||||
// events from this relay nor block re-insertion of new ones.
|
||||
db.assertQuery(vanishElsewhere, Filter(ids = listOf(vanishElsewhere.id)))
|
||||
db.assertQuery(note, Filter(ids = listOf(note.id)))
|
||||
|
||||
val newNote = signer.sign(TextNoteEvent.build("still accepted", createdAt = time + 2))
|
||||
db.insert(newNote)
|
||||
db.assertQuery(newNote, Filter(ids = listOf(newNote.id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInsertDeleteGiftWrap() =
|
||||
forEachDB { db ->
|
||||
|
||||
+40
@@ -22,11 +22,16 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
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.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlin.test.Test
|
||||
|
||||
class SearchTest : BaseDBTest() {
|
||||
val signer = NostrSignerSync()
|
||||
|
||||
companion object {
|
||||
val profile =
|
||||
MetadataEvent(
|
||||
@@ -89,4 +94,39 @@ class SearchTest : BaseDBTest() {
|
||||
db.assertQuery(comment, Filter(search = "testing"))
|
||||
db.assertQuery(comment, Filter(kinds = listOf(CommentEvent.KIND), search = "testing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFtsCleanedUpAfterReplaceableRotation() =
|
||||
forEachDB { db ->
|
||||
// The fts_foreign_key trigger fires on event_headers DELETE, so
|
||||
// when an addressable is superseded its FTS row should also be
|
||||
// removed. Otherwise stale content would keep matching searches.
|
||||
val time = TimeUtils.now()
|
||||
val v1 =
|
||||
signer.sign(
|
||||
LongTextNoteEvent.build(
|
||||
"first version uniqalpha",
|
||||
title = "blog title",
|
||||
dTag = "fts-rotation",
|
||||
createdAt = time,
|
||||
),
|
||||
)
|
||||
val v2 =
|
||||
signer.sign(
|
||||
LongTextNoteEvent.build(
|
||||
"second version uniqbeta",
|
||||
title = "blog title",
|
||||
dTag = "fts-rotation",
|
||||
createdAt = time + 1,
|
||||
),
|
||||
)
|
||||
|
||||
db.store.insertEvent(v1)
|
||||
db.assertQuery(v1, Filter(search = "uniqalpha"))
|
||||
|
||||
db.store.insertEvent(v2)
|
||||
// v1's FTS row must be gone; v2's content takes over.
|
||||
db.assertQuery(null, Filter(search = "uniqalpha"))
|
||||
db.assertQuery(v2, Filter(search = "uniqbeta"))
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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.sql
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class SqlSelectionBuilderTest {
|
||||
@Test
|
||||
fun equalsWithNullProducesIsNull() {
|
||||
val clause = where { equals("col", null) }
|
||||
assertEquals("col IS NULL", clause.conditions)
|
||||
assertEquals(emptyList(), clause.args)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notEqualsWithNullProducesIsNotNull() {
|
||||
val clause = where { notEquals("col", null) }
|
||||
assertEquals("col IS NOT NULL", clause.conditions)
|
||||
assertEquals(emptyList(), clause.args)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notEqualsWithValueProducesPlaceholder() {
|
||||
val clause = where { notEquals("col", "x") }
|
||||
assertEquals("col != ?", clause.conditions)
|
||||
assertEquals(listOf("x"), clause.args)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyInBecomesAlwaysFalse() {
|
||||
val clause = where { isIn("col", emptyList()) }
|
||||
assertEquals("1 = 0", clause.conditions)
|
||||
assertEquals(emptyList(), clause.args)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun equalsOrInPicksEqualsForSingleton() {
|
||||
val clause = where { equalsOrIn("col", listOf("x")) }
|
||||
assertEquals("col = ?", clause.conditions)
|
||||
assertEquals(listOf("x"), clause.args)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun equalsOrInPicksInForMultiple() {
|
||||
val clause = where { equalsOrIn("col", listOf("a", "b", "c")) }
|
||||
assertEquals("col IN (?, ?, ?)", clause.conditions)
|
||||
assertEquals(listOf("a", "b", "c"), clause.args)
|
||||
}
|
||||
}
|
||||
+49
-25
@@ -111,10 +111,16 @@ open class FsEventStore(
|
||||
|
||||
val slot = slots.slotPathFor(event)
|
||||
val existingSlot = slot?.let { slots.readSlot(it) }
|
||||
if (existingSlot != null && existingSlot.createdAt >= event.createdAt) {
|
||||
// Newer or equal-timestamp version already owns this slot.
|
||||
// Matches ReplaceableModule / AddressableModule blocking
|
||||
// behaviour in SQLite.
|
||||
if (existingSlot != null &&
|
||||
(
|
||||
existingSlot.createdAt > event.createdAt ||
|
||||
(existingSlot.createdAt == event.createdAt && existingSlot.id <= event.id)
|
||||
)
|
||||
) {
|
||||
// Existing slot winner outranks the incoming event under NIP-01
|
||||
// (later createdAt, or same createdAt with the lexically smaller
|
||||
// id). Matches the ReplaceableModule / AddressableModule trigger
|
||||
// condition in SQLite.
|
||||
return
|
||||
}
|
||||
|
||||
@@ -178,12 +184,17 @@ open class FsEventStore(
|
||||
|
||||
/**
|
||||
* NIP-09 pre-insert guard. Parity with SQLite's `reject_deleted_events`
|
||||
* BEFORE INSERT trigger: id-scoped tombstones always block; address-
|
||||
* scoped tombstones block when the existing cutoff is `>=` the new
|
||||
* event's createdAt.
|
||||
* BEFORE INSERT trigger: id and addr tombstones only block when the
|
||||
* deletion's author matches the candidate event's owner pubkey —
|
||||
* SQLite checks `event_tags.pubkey_hash = NEW.pubkey_owner_hash`. For
|
||||
* GiftWraps the owner is the recipient (p-tag), matching `pubkey_
|
||||
* owner_hash`. Addr tombstones additionally enforce the
|
||||
* `event.createdAt <= cutoff` window.
|
||||
*/
|
||||
private fun isBlockedByTombstone(event: Event): Boolean {
|
||||
if (tombstones.hasIdTombstone(event.id)) return true
|
||||
val ownerPubKey = indexer.ownerPubKey(event)
|
||||
val tombstoneAuthor = tombstones.idTombstoneOwnerPubKey(event.id)
|
||||
if (tombstoneAuthor != null && tombstoneAuthor == ownerPubKey) return true
|
||||
if (event is AddressableEvent && event.kind.isAddressable()) {
|
||||
val cutoff = tombstones.addrTombstoneCutoff(event.kind, event.pubKey, event.dTag())
|
||||
if (cutoff != null && event.createdAt <= cutoff) return true
|
||||
@@ -227,22 +238,25 @@ open class FsEventStore(
|
||||
}
|
||||
|
||||
for (addr in deletion.deleteAddresses()) {
|
||||
// Cascade is only honoured when the address's author matches
|
||||
// the deletion author — matching SQLite's WHERE pubkey = ?.
|
||||
if (addr.pubKeyHex == deletion.pubKey) {
|
||||
val slot =
|
||||
when {
|
||||
addr.kind.isReplaceable() -> layout.replaceableSlot(addr.kind, addr.pubKeyHex)
|
||||
addr.kind.isAddressable() -> layout.addressableSlot(addr.kind, addr.pubKeyHex, addr.dTag)
|
||||
else -> null
|
||||
}
|
||||
if (slot != null) {
|
||||
val winner = slots.readSlot(slot)
|
||||
if (winner != null && winner.createdAt <= deletion.createdAt) {
|
||||
indexer.unlink(winner)
|
||||
layout.canonical(winner.id).deleteIfExists()
|
||||
slots.clear(slot)
|
||||
}
|
||||
// Per NIP-09 only the original author can delete their own
|
||||
// addressable. If the deletion's author doesn't own the
|
||||
// address, ignore both the cascade AND the tombstone install
|
||||
// — otherwise a stranger's stray `a`-tag would block the
|
||||
// legitimate owner from re-publishing.
|
||||
if (addr.pubKeyHex != deletion.pubKey) continue
|
||||
|
||||
val slot =
|
||||
when {
|
||||
addr.kind.isReplaceable() -> layout.replaceableSlot(addr.kind, addr.pubKeyHex)
|
||||
addr.kind.isAddressable() -> layout.addressableSlot(addr.kind, addr.pubKeyHex, addr.dTag)
|
||||
else -> null
|
||||
}
|
||||
if (slot != null) {
|
||||
val winner = slots.readSlot(slot)
|
||||
if (winner != null && winner.createdAt <= deletion.createdAt) {
|
||||
indexer.unlink(winner)
|
||||
layout.canonical(winner.id).deleteIfExists()
|
||||
slots.clear(slot)
|
||||
}
|
||||
}
|
||||
tombstones.installAddr(addr.kind, addr.pubKeyHex, addr.dTag, deletion, deletionCanonical)
|
||||
@@ -323,17 +337,27 @@ open class FsEventStore(
|
||||
// Delete
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Safe-by-default: an empty filter (or a list of only empty filters)
|
||||
* deletes nothing, so a stray `delete(Filter())` cannot wipe the
|
||||
* entire store. This is asymmetric with `query(Filter())` which
|
||||
* intentionally returns every event — same contract as `SQLiteEventStore`.
|
||||
*/
|
||||
override fun delete(filter: Filter) =
|
||||
lockManager.withWriteLock {
|
||||
if (filter.isEmpty()) return@withWriteLock
|
||||
val ids = ArrayList<HexKey>()
|
||||
query<Event>(filter) { ids.add(it.id) }
|
||||
ids.forEach { deleteLocked(it) }
|
||||
}
|
||||
|
||||
/** See [delete] for the empty-filter contract. */
|
||||
override fun delete(filters: List<Filter>) =
|
||||
lockManager.withWriteLock {
|
||||
val nonEmpty = filters.filterNot { it.isEmpty() }
|
||||
if (nonEmpty.isEmpty()) return@withWriteLock
|
||||
val ids = HashSet<HexKey>()
|
||||
query<Event>(filters) { ids.add(it.id) }
|
||||
query<Event>(nonEmpty) { ids.add(it.id) }
|
||||
ids.forEach { deleteLocked(it) }
|
||||
}
|
||||
|
||||
|
||||
@@ -93,11 +93,17 @@ internal class FsIndexer(
|
||||
}
|
||||
}
|
||||
|
||||
fun ownerHash(event: Event): Long =
|
||||
fun ownerHash(event: Event): Long = hasher.hash(ownerPubKey(event))
|
||||
|
||||
/**
|
||||
* Pubkey-form counterpart of [ownerHash]. Same SQLite parity rule —
|
||||
* recipient (p-tag) for GiftWraps, sender pubkey otherwise.
|
||||
*/
|
||||
fun ownerPubKey(event: Event): String =
|
||||
if (event is GiftWrapEvent) {
|
||||
event.recipientPubKey()?.let { hasher.hash(it) } ?: hasher.hash(event.pubKey)
|
||||
event.recipientPubKey() ?: event.pubKey
|
||||
} else {
|
||||
hasher.hash(event.pubKey)
|
||||
event.pubKey
|
||||
}
|
||||
|
||||
private fun createLink(
|
||||
|
||||
@@ -48,10 +48,12 @@ import kotlin.io.path.readText
|
||||
* Tnew = incoming event.createdAt
|
||||
* Told = current slot winner's createdAt (if any)
|
||||
*
|
||||
* Tnew > Told → atomically install new slot, evict old
|
||||
* Tnew <= Told → reject the insert entirely
|
||||
* no existing slot → install new slot
|
||||
* not replaceable/addr. → no-op (kinds that don't have slot semantics)
|
||||
* Tnew > Told → atomically install new slot, evict old
|
||||
* Tnew == Told && id_new < id_old → install new (NIP-01 lexical id tiebreaker)
|
||||
* Tnew == Told && id_new >= id_old → reject the insert entirely
|
||||
* Tnew < Told → reject the insert entirely
|
||||
* no existing slot → install new slot
|
||||
* not replaceable/addr. → no-op (kinds that don't have slot semantics)
|
||||
*
|
||||
* Eviction deletes the old canonical file and all its index hardlinks.
|
||||
*/
|
||||
@@ -76,17 +78,17 @@ internal class FsSlots(
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-insert check. Returns the *existing* winner if it blocks the
|
||||
* insert (i.e. its createdAt is >= incoming.createdAt). Returns null
|
||||
* when insertion may proceed — possibly with an evictable older winner,
|
||||
* which the caller looks up separately via [readSlot].
|
||||
* Pre-insert check. Returns true when the existing slot winner
|
||||
* outranks the incoming event. NIP-01 ranking is `(createdAt DESC, id ASC)` —
|
||||
* later timestamp wins, and on a tie the lexicographically smaller id wins.
|
||||
*/
|
||||
fun shouldBlock(
|
||||
event: Event,
|
||||
slot: Path,
|
||||
): Boolean {
|
||||
val existing = readSlot(slot) ?: return false
|
||||
return existing.createdAt >= event.createdAt
|
||||
return existing.createdAt > event.createdAt ||
|
||||
(existing.createdAt == event.createdAt && existing.id <= event.id)
|
||||
}
|
||||
|
||||
fun readSlot(slot: Path): Event? {
|
||||
|
||||
+30
-4
@@ -42,12 +42,20 @@ import kotlin.io.path.readText
|
||||
* empty d for replaceables)
|
||||
*
|
||||
* Semantics:
|
||||
* - An `id` tombstone unconditionally blocks re-insertion of the exact
|
||||
* event id (matching SQLite's ExistsCheck on etag_hash).
|
||||
* - An `id` tombstone blocks re-insertion of the exact event id only
|
||||
* when the deletion's author matches the candidate event's owner —
|
||||
* matching SQLite's `event_tags.pubkey_hash = NEW.pubkey_owner_hash`
|
||||
* condition in `reject_deleted_events`. The id tombstone is therefore
|
||||
* stored unconditionally (so the check survives "deletion arrives
|
||||
* before its target") and authority is checked at lookup time via
|
||||
* [idTombstoneOwnerPubKey].
|
||||
* - An `addr` tombstone holds the kind-5 `createdAt` as a cutoff. An
|
||||
* insert is blocked iff `event.createdAt <= tombstone.createdAt`.
|
||||
* Newer events at the same address may pass (matching SQLite's
|
||||
* `created_at >= NEW.created_at` trigger condition).
|
||||
* `created_at >= NEW.created_at` trigger condition). Addr tombstones
|
||||
* are only installed when `addr.pubkey == deletion.author`, since
|
||||
* the address itself carries the owner identity (NIP-09: only the
|
||||
* original author may delete their addressable).
|
||||
* - When multiple kind-5s target the same tombstone path, the one
|
||||
* with the larger `createdAt` wins (strongest cutoff). Atomic
|
||||
* rename swaps it in.
|
||||
@@ -58,7 +66,25 @@ import kotlin.io.path.readText
|
||||
internal class FsTombstones(
|
||||
private val layout: FsLayout,
|
||||
) {
|
||||
fun hasIdTombstone(id: HexKey): Boolean = layout.idTombstonePath(id).exists()
|
||||
/**
|
||||
* Returns the kind-5 deletion's author pubkey for an id tombstone, or
|
||||
* null if there is no tombstone (or it can't be parsed). The caller
|
||||
* must compare this against the candidate event's owner pubkey to
|
||||
* decide whether to honour the tombstone — a stranger's kind-5 with
|
||||
* an `e`-tag pointing at someone else's event MUST NOT block that
|
||||
* event from being (re-)inserted.
|
||||
*/
|
||||
fun idTombstoneOwnerPubKey(id: HexKey): HexKey? {
|
||||
val path = layout.idTombstonePath(id)
|
||||
if (!path.exists()) return null
|
||||
return try {
|
||||
Event.fromJson(path.readText()).pubKey
|
||||
} catch (_: java.io.IOException) {
|
||||
null
|
||||
} catch (_: com.fasterxml.jackson.core.JacksonException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the `createdAt` cutoff from the tombstone, or null if none exists. */
|
||||
fun addrTombstoneCutoff(
|
||||
|
||||
@@ -35,10 +35,10 @@ Everything documented in [`../sqlite/README.md`](../sqlite/README.md):
|
||||
| SQLite feature | This store |
|
||||
|---|---|
|
||||
| Insert + retrieve by Nostr filter | ✓ |
|
||||
| Replaceable events (kinds 0, 3, 10000-19999) — newer wins, older blocked | ✓ via `replaceable/<kind>/<pubkey>.json` slot |
|
||||
| Addressable events (kinds 30000-39999) — `(kind,pubkey,d)` uniqueness | ✓ via `addressable/<kind>/<pubkey>/<sha256(d)>.json` slot |
|
||||
| Replaceable events (kinds 0, 3, 10000-19999) — newer wins, older blocked, NIP-01 lexical-id tiebreaker on equal `created_at` | ✓ via `replaceable/<kind>/<pubkey>.json` slot |
|
||||
| Addressable events (kinds 30000-39999) — `(kind,pubkey,d)` uniqueness, same NIP-01 tiebreaker | ✓ via `addressable/<kind>/<pubkey>/<sha256(d)>.json` slot |
|
||||
| Ephemeral events never stored | ✓ rejected pre-write |
|
||||
| NIP-09 deletions — by id, by address, gift-wrap by `p`-tag, blocks re-insert | ✓ via `tombstones/id/` and `tombstones/addr/` (each tombstone is a hardlink to the kind-5 event) |
|
||||
| NIP-09 deletions — by id, by address (up to deletion's `created_at`), gift-wrap by `p`-tag, only the original author's deletions take effect | ✓ via `tombstones/id/` and `tombstones/addr/` (each tombstone is a hardlink to the kind-5 event) |
|
||||
| NIP-40 expirations — reject expired-on-insert, periodic sweep | ✓ via `idx/expires_at/` index + `deleteExpiredEvents()` |
|
||||
| NIP-45 counts | ✓ same planner, just count results |
|
||||
| NIP-50 full-text search — content tokenisation, AND of tokens | ✓ via `idx/fts/<token>/` |
|
||||
|
||||
+40
-5
@@ -67,6 +67,7 @@ class FsDeletionTest {
|
||||
slug: String,
|
||||
body: String,
|
||||
ts: Long,
|
||||
signer: NostrSignerSync = this.signer,
|
||||
) = signer.sign<LongTextNoteEvent>(
|
||||
createdAt = ts,
|
||||
kind = LongTextNoteEvent.KIND,
|
||||
@@ -74,6 +75,12 @@ class FsDeletionTest {
|
||||
content = body,
|
||||
)
|
||||
|
||||
private fun otherArticle(
|
||||
slug: String,
|
||||
body: String,
|
||||
ts: Long,
|
||||
) = article(slug, body, ts, signer = otherSigner)
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Delete by id
|
||||
// ------------------------------------------------------------------
|
||||
@@ -106,7 +113,7 @@ class FsDeletionTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deletion by non-author does not cascade but still installs id tombstone`() {
|
||||
fun `deletion by non-author neither cascades nor blocks legitimate re-insertion`() {
|
||||
// other signer authors a note
|
||||
val theirs = note("not yours", 10, signer = otherSigner)
|
||||
store.insert(theirs)
|
||||
@@ -118,12 +125,15 @@ class FsDeletionTest {
|
||||
// Cascade did NOT run — not our author.
|
||||
assertEquals(listOf(theirs.id), store.query<TextNoteEvent>(Filter(ids = listOf(theirs.id))).map { it.id })
|
||||
|
||||
// But a tombstone is installed for the id, blocking future re-inserts.
|
||||
// Re-inserting the note is effectively a no-op because it already
|
||||
// exists. Instead we delete then try to re-insert.
|
||||
// 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),
|
||||
// but when the legitimate owner deletes the local copy and the
|
||||
// event re-arrives from another relay, the tombstone must NOT
|
||||
// block it — only same-author deletions can block re-insertion.
|
||||
// Matches SQLite's `event_tags.pubkey_hash = NEW.pubkey_owner_hash`.
|
||||
store.delete(theirs.id)
|
||||
store.insert(theirs)
|
||||
assertEquals(emptyList(), 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 })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -258,6 +268,31 @@ class FsDeletionTest {
|
||||
// Tombstone files use hardlinks to the kind-5 canonical
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `non-author address deletion does not block legitimate addressable inserts`() {
|
||||
// `otherSigner` (call them Bob) authors an addressable; the
|
||||
// default `signer` (a stranger relative to Bob) then publishes a
|
||||
// kind-5 with an `a` tag pointing at Bob's address. NIP-09 says
|
||||
// only the address owner may delete it, so the stranger's event
|
||||
// must NOT install an address tombstone — otherwise Bob couldn't
|
||||
// publish a new version at the same address. Matches SQLite's
|
||||
// `event_tags.pubkey_hash = NEW.pubkey_owner_hash` guard.
|
||||
val v1 = otherArticle("shared", "v1", 10)
|
||||
store.insert(v1)
|
||||
assertEquals(listOf(v1.id), store.query<LongTextNoteEvent>(Filter(ids = listOf(v1.id))).map { it.id })
|
||||
|
||||
val strangerDel =
|
||||
signer.sign<DeletionEvent>(DeletionEvent.build(listOf(v1), createdAt = 20))
|
||||
store.insert(strangerDel)
|
||||
|
||||
// Bob can still publish a newer version at the same address. Since
|
||||
// the stranger's deletion was non-authoritative, no addr tombstone
|
||||
// exists to block.
|
||||
val v2 = otherArticle("shared", "v2", 30)
|
||||
store.insert(v2)
|
||||
assertEquals(listOf(v2.id), store.query<LongTextNoteEvent>(Filter(ids = listOf(v2.id))).map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `id tombstone is a hardlink to the kind-5 event`() {
|
||||
val n = note("x", 10)
|
||||
|
||||
+17
@@ -169,6 +169,23 @@ class FsEventStoreTest {
|
||||
assertTrue(store.count(Filter(ids = listOf(b.id))) == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete with empty filter is safe`() {
|
||||
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`() {
|
||||
val staging = root.resolve(".staging")
|
||||
|
||||
@@ -231,6 +231,58 @@ class FsParityTest {
|
||||
assertParity(Filter(authors = listOf(signer.pubKey), kinds = listOf(LongTextNoteEvent.KIND)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaceable same-createdAt lexical id tiebreaker parity`() {
|
||||
// Two kind-0 events with identical createdAt produce different ids
|
||||
// because their content differs. NIP-01 says the lexically smaller
|
||||
// id wins on a tie. Both stores must agree, regardless of insertion
|
||||
// order.
|
||||
val a =
|
||||
signer.sign<Event>(
|
||||
createdAt = 100,
|
||||
kind = 0,
|
||||
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",
|
||||
)
|
||||
assertParity(
|
||||
Filter(ids = listOf(a.id, b.id)),
|
||||
"the loser must not survive in the by-id query",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addressable same-createdAt lexical id tiebreaker parity`() {
|
||||
val a = article("tie", "version a", 100)
|
||||
val b = article("tie", "version b", 100)
|
||||
|
||||
insertBoth(a)
|
||||
insertBoth(b)
|
||||
|
||||
assertParity(
|
||||
Filter(
|
||||
authors = listOf(signer.pubKey),
|
||||
kinds = listOf(LongTextNoteEvent.KIND),
|
||||
tags = mapOf("d" to listOf("tie")),
|
||||
),
|
||||
)
|
||||
assertParity(Filter(ids = listOf(a.id, b.id)))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Deletion (NIP-09)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
+24
-4
@@ -102,15 +102,35 @@ class FsSlotsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `equal timestamp replaceable is rejected`() {
|
||||
fun `equal timestamp replaceable resolves by lexical id (NIP-01)`() {
|
||||
val a = metadata("a", 100)
|
||||
val b = metadata("b", 100)
|
||||
store.insert(a)
|
||||
store.insert(b)
|
||||
// NIP-01 tiebreaker: when createdAt ties, the lexically smaller
|
||||
// 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.
|
||||
store.insert(loser)
|
||||
store.insert(winner)
|
||||
|
||||
val got = store.query<MetadataEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND)))
|
||||
assertEquals(1, got.size)
|
||||
assertEquals(a.id, got.single().id)
|
||||
assertEquals(winner.id, got.single().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `equal timestamp replaceable rejects higher id when winner already present`() {
|
||||
val a = metadata("a", 100)
|
||||
val b = metadata("b", 100)
|
||||
val (winner, loser) = if (a.id < b.id) a to b else b to a
|
||||
|
||||
// Winner inserted first — loser must NOT take the slot.
|
||||
store.insert(winner)
|
||||
store.insert(loser)
|
||||
|
||||
val got = store.query<MetadataEvent>(Filter(authors = listOf(signer.pubKey), kinds = listOf(MetadataEvent.KIND)))
|
||||
assertEquals(1, got.size)
|
||||
assertEquals(winner.id, got.single().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user