fix(quartz/sqlite): Batch A correctness/consistency fixes
- isExpired (#10): NIP-40 says an event is expired *once* `expiration` is reached, and the SQL trigger uses `<= unixepoch()`. The Kotlin pre-check used `<` (strict) so an event with `expiration == now` passed the Kotlin check then failed in the trigger. Both layers now use `<=`. Also applies to `isExpirationBefore` for consistency. - transaction extension (#7): if the body throws *and* ROLLBACK also throws, we now attach the rollback failure as a suppressed exception instead of letting it mask the original cause. COMMIT is moved outside the catch so a commit failure doesn't trigger a second ROLLBACK on already-finalized transaction state. - SeedModule.hasher (#8): the cache field is now a `kotlin.concurrent. atomics.AtomicReference` (matches the pattern used in BleChunkAssembler and BasicRelayClient) so the hasher publication is visible across threads. The race itself is benign — the seed is stable, so two concurrent computations produce identical hashers — but the prior plain `var` had no visibility guarantee. - delete(Filter()) (#12): documents the intentional asymmetry — `query` on an empty filter returns everything, but `delete` on an empty filter is a no-op (safe-by-default). New test pins the contract. Tests: - testInsertingEventExpiringExactlyNow: events with `expiration == now` are rejected by both Kotlin and the trigger. - testTransactionRollsBackOnException: a user transaction whose body throws leaves the DB unchanged and still accepts new writes. - testDeleteWithEmptyFilterIsSafe: empty-filter delete is a no-op. https://claude.ai/code/session_01X163Nr31vGkvAXoJ3JgMov
This commit is contained in:
+53
@@ -200,6 +200,59 @@ 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 testSchemaRecreateIsIdempotent() =
|
||||
forEachDB { db ->
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user