feat(quartz): allow SQLite event store without FTS indexing

Add an `enableFullTextSearch` flag (default `true`) to `EventStore` and
`SQLiteEventStore` so deployments that never serve NIP-50 search from
SQLite — e.g. a relay that offloads search to an external engine like
Vespa — can skip the full-text-search write cost.

When disabled:
- `FullTextSearchModule` becomes an inert no-op: the `event_fts` virtual
  table and its `fts_foreign_key` delete trigger are never created,
  inserts skip `indexableContent()` + tokenization, and both reindex
  entry points return immediately.
- `QueryBuilder` short-circuits any query/count/delete filter carrying a
  non-empty `search` term to a "matches nothing" result (an empty-string
  search still imposes no constraint), so no SQL ever references the
  absent `event_fts` table. In a multi-filter union the search branch
  contributes nothing while the other filters resolve normally.

Everything else (replaceable/addressable handling, deletions,
expirations, right-to-vanish, negentropy) is unchanged, and the default
keeps FTS on for existing callers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjzUpY8H31c7ux669zytWg
This commit is contained in:
Claude
2026-07-01 21:00:06 +00:00
parent 03c71d1d4e
commit cd4edefce0
6 changed files with 311 additions and 4 deletions
@@ -38,8 +38,16 @@ class EventStore(
dbName: String? = "events.db",
override val relay: NormalizedRelayUrl? = "wss://quartz.local/".normalizeRelayUrl(),
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
/**
* When `false`, NIP-50 full-text search indexing is turned off: no
* `event_fts` table or delete trigger is created, inserts skip the
* FTS tokenization cost, and `search` filters return no matches.
* Use for stores that never serve search from SQLite (e.g. a relay
* that offloads NIP-50 to an external engine like Vespa).
*/
val enableFullTextSearch: Boolean = true,
) : IEventStore {
val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relay, indexStrategy)
val store = SQLiteEventStore(BundledSQLiteDriver(), dbName, relay, indexStrategy, enableFullTextSearch = enableFullTextSearch)
override suspend fun insert(event: Event) = store.insertEvent(event)
@@ -28,13 +28,28 @@ import com.vitorpamplona.quartz.nip01Core.store.FtsReindexProgress
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.EventFactory
class FullTextSearchModule : IModule {
/**
* NIP-50 full-text search index over event content.
*
* When [enabled] is `false` the module becomes an inert no-op: no
* `event_fts` virtual table and no `fts_foreign_key` delete trigger are
* created, inserts skip the per-event tokenization cost, and the reindex
* entry points do nothing. This is for deployments that never query the
* SQLite store for search — e.g. a relay that offloads NIP-50 to an
* external engine such as Vespa — and don't want to pay the write-time
* FTS indexing (or the per-delete trigger) overhead. Search filters are
* handled by [QueryBuilder], which returns no matches when search is off.
*/
class FullTextSearchModule(
val enabled: Boolean = true,
) : IModule {
val tableName = "event_fts"
val triggerName = "fts_foreign_key"
val eventHeaderRowIdName = "event_header_row_id"
val contentName = "content"
override fun create(db: SQLiteConnection) {
if (!enabled) return
val ftsVersion = versionFinder(db)
db.execSQL(
"""
@@ -88,6 +103,7 @@ class FullTextSearchModule : IModule {
headerId: Long,
db: SQLiteConnection,
) {
if (!enabled) return
if (event is SearchableEvent) {
db.prepare(insertFTS).use { stmt ->
stmt.bindLong(1, headerId)
@@ -127,6 +143,7 @@ class FullTextSearchModule : IModule {
}
override fun deleteAll(db: SQLiteConnection) {
if (!enabled) return
db.execSQL("DELETE FROM event_fts")
}
@@ -144,6 +161,7 @@ class FullTextSearchModule : IModule {
* Must run inside the caller's write transaction.
*/
fun reindexAll(db: SQLiteConnection) {
if (!enabled) return
dropTrigger(db)
drop(db)
create(db)
@@ -199,6 +217,7 @@ class FullTextSearchModule : IModule {
afterRowId: Long,
batchSize: Int,
): FtsReindexProgress {
if (!enabled) return FtsReindexProgress(cursor = null, processedThisBatch = 0, done = true)
// A non-positive page would select no rows yet never report done,
// spinning the caller's loop forever — clamp to at least one.
val limit = batchSize.coerceAtLeast(1)
@@ -114,6 +114,12 @@ class QueryBuilder(
): QuerySpec {
val newFilter = filter.toFilterWithDTags()
// With FTS off there is no event_fts table to MATCH against, so a
// search term can never be satisfied — the filter matches nothing.
if (searchDisabledMatchesNothing(newFilter.search)) {
return QuerySpec(makeMatchesNothingQuery())
}
if (newFilter.isSimpleQuery()) {
return makeSimpleQuery(
project = true,
@@ -215,6 +221,11 @@ class QueryBuilder(
): QuerySpec {
val newFilter = filter.toFilterWithDTags()
// With FTS off a search term can never be satisfied — no matches.
if (searchDisabledMatchesNothing(newFilter.search)) {
return QuerySpec("SELECT id, created_at FROM event_headers WHERE 0")
}
// Simple path — no tag joins, no FTS — collapses to a single
// SELECT against event_headers.
if (newFilter.isSimpleQuery()) {
@@ -389,6 +400,18 @@ class QueryBuilder(
results
}
/**
* True when full-text search is turned off ([FullTextSearchModule.enabled]
* is `false`) and the filter carries a non-empty `search` term. Such a
* filter can never be satisfied — there is no `event_fts` table to
* MATCH — so callers short-circuit to a "matches nothing" query. An
* empty-string search imposes no constraint and is left to the normal
* (non-search) query path, matching the FTS-enabled behaviour.
*/
private fun searchDisabledMatchesNothing(search: String?): Boolean = !fts.enabled && search != null && search.isNotEmpty()
private fun makeMatchesNothingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE 0"
private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""}"
private fun makeQueryIn(rowIdQuery: String) =
@@ -480,6 +503,9 @@ class QueryBuilder(
): Int {
val newFilter = filter.toFilterWithDTags()
// With FTS off a search term can never be satisfied — count is 0.
if (searchDisabledMatchesNothing(newFilter.search)) return 0
if (newFilter.isSimpleQuery()) {
val sql =
makeSimpleQuery(
@@ -622,7 +648,15 @@ class QueryBuilder(
): QuerySpec? {
if (filter.isEmpty()) return null
val mustJoinSearch = (filter.search != null)
// With FTS off there is no event_fts table to MATCH/JOIN against.
// A non-empty search can never match, so this filter contributes
// no row ids (returns an empty branch to any UNION/COUNT/DELETE);
// an empty search imposes no constraint and is simply dropped below.
if (searchDisabledMatchesNothing(filter.search)) {
return QuerySpec("SELECT event_headers.row_id as row_id FROM event_headers WHERE 0")
}
val mustJoinSearch = filter.search != null && fts.enabled
val nonDTagsIn = filter.tags?.filter { it.key != "d" } ?: emptyMap()
@@ -189,6 +189,36 @@ val result = eventStore.query(Filter(search = "bitcoin", limit = 20))
This will match any event whose content contains "bitcoin", returning the most recent 20 results.
#### Disabling full-text search
FTS indexing has a write-time cost: every inserted `SearchableEvent` is
tokenized into the `event_fts` virtual table, and an `AFTER DELETE`
trigger keeps that table in sync on every deletion. If you never serve
NIP-50 search from this store — e.g. a relay that offloads search to an
external engine like Vespa — pass `enableFullTextSearch = false`:
```kotlin
val eventStore = EventStore("dbname.db", relayUrlIdentifier, enableFullTextSearch = false)
```
With FTS off:
- The `event_fts` table and its `fts_foreign_key` delete trigger are
never created (no schema dependency on FTS3/4/5 being compiled in).
- Inserts skip the per-event `indexableContent()` + tokenization work,
and deletes skip the trigger.
- Any query/count/delete filter carrying a non-empty `search` term
returns **no matches** (an empty-string search imposes no constraint
and behaves like a normal query). Everything else — replaceable and
addressable handling, deletions, expirations, right-to-vanish,
negentropy — is unchanged.
The flag is chosen at store-construction time and only governs whether
new writes maintain the index; it does not migrate an existing database
(it won't drop an `event_fts` table left over from a store previously
opened with FTS enabled). Pick the mode when the database is first
created.
### Periodic cleanup of expired events.
The store exposes a `deleteExpiredEvents` to be used in a periodic clean up procedure. Users
@@ -43,6 +43,14 @@ class SQLiteEventStore(
val relay: NormalizedRelayUrl? = null,
val indexStrategy: IndexingStrategy = DefaultIndexingStrategy(),
val numReaders: Int = 4,
/**
* When `false`, NIP-50 full-text search indexing is turned off: no
* `event_fts` table or delete trigger is created, inserts skip the
* FTS tokenization cost, and `search` filters return no matches.
* Use for stores that never serve search from SQLite (e.g. a relay
* that offloads NIP-50 to an external engine like Vespa).
*/
val enableFullTextSearch: Boolean = true,
) {
companion object {
const val DATABASE_VERSION = 2
@@ -50,7 +58,7 @@ class SQLiteEventStore(
val seedModule = SeedModule()
val fullTextSearchModule = FullTextSearchModule()
val fullTextSearchModule = FullTextSearchModule(enableFullTextSearch)
val eventIndexModule =
EventIndexesModule(
seedModule::hasher,
@@ -0,0 +1,208 @@
/*
* 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
import androidx.sqlite.SQLiteConnection
import com.vitorpamplona.quartz.nip01Core.core.Event
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.utils.Secp256k1Instance
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.runBlocking
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Exercises a [SQLiteEventStore] created with `enableFullTextSearch = false`.
*
* The store must behave exactly like a normal store for every non-search
* operation while (a) never creating the `event_fts` table or its delete
* trigger and (b) treating any non-empty `search` term as matching nothing.
* This is the mode a relay uses when it offloads NIP-50 search to an
* external engine (e.g. Vespa) and doesn't want to pay the FTS index cost.
*/
class NoFullTextSearchTest {
private val signer = NostrSignerSync()
@BeforeTest
fun setup() {
Secp256k1Instance
}
private fun store() = SQLiteEventStore(dbName = null, enableFullTextSearch = false)
private fun objectExists(
db: SQLiteConnection,
name: String,
): Boolean =
db.prepare("SELECT name FROM sqlite_master WHERE name = ?").use { stmt ->
stmt.bindText(1, name)
stmt.step()
}
@Test
fun testNoFtsTableOrTriggerIsCreated() =
runBlocking {
val store = store()
try {
// Force schema creation by opening the pool for a read.
store.pool.useReader { db ->
assertFalse(objectExists(db, "event_fts"), "event_fts table must not exist when FTS is disabled")
assertFalse(objectExists(db, "fts_foreign_key"), "fts_foreign_key trigger must not exist when FTS is disabled")
// Sanity: the canonical table is still there.
assertTrue(objectExists(db, "event_headers"), "event_headers must still be created")
}
} finally {
store.close()
}
}
@Test
fun testInsertAndPlainQueriesStillWork() =
runBlocking {
val store = store()
try {
val note = signer.sign(TextNoteEvent.build("hello nofts world", createdAt = TimeUtils.now()))
store.insertEvent(note)
// Query by id, kind, and author all resolve normally.
store.assertQuery(note, Filter(ids = listOf(note.id)))
store.assertQuery(note, Filter(kinds = listOf(TextNoteEvent.KIND)))
store.assertQuery(note, Filter(authors = listOf(note.pubKey)))
assertEquals(1, store.count(Filter(kinds = listOf(TextNoteEvent.KIND))))
} finally {
store.close()
}
}
@Test
fun testSearchFilterMatchesNothing() =
runBlocking {
val store = store()
try {
// Content clearly contains the term; with FTS off it must not match.
val note = signer.sign(TextNoteEvent.build("searchable bitcoin content", createdAt = TimeUtils.now()))
store.insertEvent(note)
store.assertQuery(null, Filter(search = "bitcoin"))
store.assertQuery(null, Filter(kinds = listOf(TextNoteEvent.KIND), search = "bitcoin"))
store.assertQuery(null, Filter(authors = listOf(note.pubKey), search = "bitcoin"))
assertEquals(0, store.count(Filter(search = "bitcoin")))
} finally {
store.close()
}
}
@Test
fun testMultiFilterUnionDropsSearchBranchButKeepsOthers() =
runBlocking {
val store = store()
try {
val note = signer.sign(TextNoteEvent.build("multi filter bitcoin note", createdAt = TimeUtils.now()))
store.insertEvent(note)
// The search branch contributes nothing; the kind branch still returns the note.
val results =
store.query<Event>(
listOf(
Filter(search = "bitcoin"),
Filter(kinds = listOf(TextNoteEvent.KIND)),
),
)
assertEquals(1, results.size)
assertEquals(note.id, results.first().id)
assertEquals(
1,
store.count(
listOf(
Filter(search = "bitcoin"),
Filter(kinds = listOf(TextNoteEvent.KIND)),
),
),
)
} finally {
store.close()
}
}
@Test
fun testDeleteWithSearchFilterRemovesNothing() =
runBlocking {
val store = store()
try {
val note = signer.sign(TextNoteEvent.build("delete me bitcoin", createdAt = TimeUtils.now()))
store.insertEvent(note)
store.delete(Filter(search = "bitcoin"))
// The event survives — a search delete cannot resolve to any rows.
store.assertQuery(note, Filter(ids = listOf(note.id)))
} finally {
store.close()
}
}
@Test
fun testEmptySearchStringImposesNoConstraint() =
runBlocking {
val store = store()
try {
val note = signer.sign(TextNoteEvent.build("empty search body", createdAt = TimeUtils.now()))
store.insertEvent(note)
// An empty search term is not a search; the filter still returns the note.
store.assertQuery(note, Filter(kinds = listOf(TextNoteEvent.KIND), search = ""))
} finally {
store.close()
}
}
@Test
fun testReindexIsANoOp() =
runBlocking {
val store = store()
try {
val note = signer.sign(TextNoteEvent.build("reindex noop body", createdAt = TimeUtils.now()))
store.insertEvent(note)
// Neither reindex entry point should throw or create an index.
store.reindexFullTextSearch()
val progress = store.reindexFullTextSearch(resumeFrom = null, batchSize = 100)
assertTrue(progress.done, "batched reindex must report done immediately when FTS is disabled")
assertEquals(0, progress.processedThisBatch)
store.pool.useReader { db ->
assertFalse(objectExists(db, "event_fts"), "reindex must not create event_fts when FTS is disabled")
}
// Search still matches nothing after the no-op rebuild.
store.assertQuery(null, Filter(search = "reindex"))
} finally {
store.close()
}
}
}