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:
+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