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:
Claude
2026-04-25 12:32:11 +00:00
parent fb478d6019
commit 28b23b5e63
10 changed files with 265 additions and 51 deletions
@@ -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 ->
@@ -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 ->
@@ -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)))
}
}
@@ -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 ->