fix(quartz/sqlite): NIP compliance and migration safety in event store
- NIP-09 (#2): a-tag and replaceable deletes now respect `created_at <= deletion.created_at` so a stale deletion request cannot remove a newer addressable that legitimately replaced it. `+created_at` hint on the addressable path keeps the d_tag-selective index in use. - NIP-01 (#1): replaceable / addressable triggers now apply the lexical-id tiebreaker — when two events share `created_at`, the one with the lexicographically smaller id wins, matching the spec. - Schema (#5): FullTextSearchModule.versionFinder probes FTS support with a dummy table, but used to leave it behind. The first v1->v2 upgrade then failed because re-running create() would hit "already exists". Now we drop the probe table immediately and defensively clean up any stragglers. - Schema (#6): onCreate / onUpgrade and the matching `setUserVersion` are now wrapped in a single transaction so a partial migration cannot leave the DB with mismatched user_version and schema. - SQL DSL (#4): Condition.NotEquals(null) now produces `IS NOT NULL` instead of `IS NULL`. - Doc fix (#13): swapped vacuum/analyse comments now describe the right command. Tests: same-`created_at` tiebreaker for replaceables and addressables, NIP-09 created_at window for a-tag deletes, schema drop+recreate idempotency (covers the FTS dummy-table regression). https://claude.ai/code/session_01X163Nr31vGkvAXoJ3JgMov
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")
|
||||
|
||||
+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(),
|
||||
)
|
||||
|
||||
+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")
|
||||
}
|
||||
|
||||
+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} != ?"
|
||||
|
||||
+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 ->
|
||||
|
||||
+19
@@ -200,6 +200,25 @@ class BasicTest : BaseDBTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@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 ->
|
||||
|
||||
+68
-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,50 @@ class DeletionTest : BaseDBTest() {
|
||||
db.store.explainQuery(sql.sql, sql.args),
|
||||
)
|
||||
}
|
||||
|
||||
@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)))
|
||||
}
|
||||
}
|
||||
|
||||
+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 ->
|
||||
|
||||
Reference in New Issue
Block a user