From 7d527bdce30202d51fcf8d9bc3b4b10737a83325 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 16:15:21 +0000 Subject: [PATCH] feat(geode): let operators pick any quartz IEventStore backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Geode hard-wired the SQLite EventStore. Add a `[database].backend` selector (and `--store` CLI flag) so an operator can choose the store implementation: - "sqlite" (default): the SQLite EventStore, unchanged. - "fs": quartz's filesystem FsEventStore, rooted at [database].file. - any other value: a fully-qualified class name of a custom IEventStore on the classpath, instantiated reflectively via one of `(NormalizedRelayUrl?, IndexingStrategy)`, `(NormalizedRelayUrl?)`, or `()` — the "plug in anything" escape hatch. Store construction moves into a new StoreFactory (mirrors cli's StoreFactory) shared by the serve path and the import/export verbs, so both open the same store from the same config. The SQLite-only `PRAGMA optimize` maintenance loop now runs only when the resolved store is the SQLite one. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GG3TBvLUv5uB5js1naG5sc --- geode/config.example.toml | 15 +- .../kotlin/com/vitorpamplona/geode/Main.kt | 90 ++++++--- .../com/vitorpamplona/geode/StoreFactory.kt | 177 ++++++++++++++++++ .../geode/config/StaticConfig.kt | 25 +++ .../vitorpamplona/geode/StoreFactoryTest.kt | 122 ++++++++++++ .../geode/config/StaticConfigTest.kt | 13 ++ 6 files changed, 411 insertions(+), 31 deletions(-) create mode 100644 geode/src/main/kotlin/com/vitorpamplona/geode/StoreFactory.kt create mode 100644 geode/src/test/kotlin/com/vitorpamplona/geode/StoreFactoryTest.kt diff --git a/geode/config.example.toml b/geode/config.example.toml index 8ae62edc10..2ba85e0ad4 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -41,8 +41,21 @@ path = "/" # call_group_size = 64 [database] +# Which IEventStore implementation backs the relay. Default "sqlite". +# - "sqlite": quartz's SQLite store — honours every knob below. Best for +# real traffic. CLI: --store sqlite. +# - "fs": quartz's filesystem store — one JSON file per event under the +# directory named by `file` (a directory, not a db file, in this mode). +# Human-inspectable with cat/jq; the SQLite-only knobs are ignored, and +# it always needs `file`. CLI: --store fs. +# Any other value is the fully-qualified class name of a custom IEventStore +# on the classpath, instantiated reflectively (needs a public constructor +# `(NormalizedRelayUrl?, IndexingStrategy)`, `(NormalizedRelayUrl?)`, or `()`). +# backend = "sqlite" + # True keeps an in-memory SQLite db (events vanish on restart). Useful -# for tests; set false + `file = "..."` for persistent storage. +# for tests; set false + `file = "..."` for persistent storage. Ignored +# by the "fs" backend, which always writes to the `file` directory. in_memory = false file = "/var/lib/geode/events.db" diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index 5c30fc2f60..704b5665b7 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.OptionalAuthPoli import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyAuthOnlyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy +import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.NdjsonImportExport import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings @@ -94,6 +95,9 @@ import java.io.File * --path

ws path (default from config or /) * --info NIP-11 doc file (overrides [info] section) * --db sqlite db path (overrides [database].file) + * --store event-store backend (overrides [database].backend): + * "sqlite" (default), "fs", or the fully-qualified + * class name of a custom IEventStore on the classpath. * --auth require NIP-42 AUTH (sets options.require_auth = true) * --optional-auth advertise NIP-42 AUTH but don't require it (sets * options.optional_auth = true; ignored when --auth is set) @@ -123,21 +127,34 @@ fun main(args: Array) { * exact store the server would. */ private class StoreContext( + /** Human-readable location of the store's bytes, for the verb's summary line. */ val dbFile: String?, - val store: EventStore, + val store: IEventStore, ) private fun openStore(a: Args): StoreContext { - val config = a.opt(CONFIG_FLAG)?.let { StaticConfig.fromFile(File(it)) } ?: StaticConfig() - val dbFile = a.opt("--db") ?: config.database.file?.takeUnless { config.database.in_memory } + val config = + (a.opt(CONFIG_FLAG)?.let { StaticConfig.fromFile(File(it)) } ?: StaticConfig()) + .withStoreOverride(a.opt(STORE_FLAG)) val fullTextSearch = !a.flag(NO_SEARCH_FLAG) && config.options.full_text_search + // The verbs operate on the same store the server would, honoring the same + // `[database].backend` selection — unscoped (relay = null) because bulk + // load/dump has no single relay to attribute NIP-62 cascades to. val store = - EventStore( - dbName = dbFile, - indexStrategy = relayIndexingStrategy(fullTextSearch, config.negentropy.live_index), - numReaders = config.database.readers ?: 4, + StoreFactory.open( + config = config, + relay = null, + fullTextSearch = fullTextSearch, + dbOverride = a.opt("--db"), ) - return StoreContext(dbFile, store) + // Where the bytes land, for the summary line. Only the SQLite in-memory + // case has no location; the FS store always has a directory. + val sqlite = + config.database.backend + .trim() + .lowercase() in StoreFactory.SQLITE_BACKEND_KEYWORDS + val location = a.opt("--db") ?: config.database.file?.takeUnless { sqlite && config.database.in_memory } + return StoreContext(location, store) } private fun runImport(args: Array) { @@ -189,10 +206,12 @@ private fun serve(args: Array) { val a = parseArgs(args) val config: StaticConfig = - a - .opt(CONFIG_FLAG) - ?.let { StaticConfig.fromFile(File(it)) } - ?: StaticConfig() + ( + a + .opt(CONFIG_FLAG) + ?.let { StaticConfig.fromFile(File(it)) } + ?: StaticConfig() + ).withStoreOverride(a.opt(STORE_FLAG)) config.validate() val host = a.opt("--host") ?: config.network.host @@ -200,7 +219,6 @@ private fun serve(args: Array) { val path = a.opt("--path") ?: config.network.path val cliInfoFile = a.opt("--info")?.let { File(it) } - val dbFile = a.opt("--db") ?: config.database.file?.takeUnless { config.database.in_memory } val requireAuth = a.flag("--auth") || config.options.require_auth // Optional AUTH advertises the challenge without gating commands on it. // Mandatory AUTH already sends the challenge, so it wins when both are set. @@ -239,11 +257,11 @@ private fun serve(args: Array) { if (config.database.temp_store_memory) add("PRAGMA temp_store = MEMORY;") } val store = - EventStore( - dbName = dbFile, + StoreFactory.open( + config = config, relay = advertisedUrl, - indexStrategy = relayIndexingStrategy(fullTextSearch, config.negentropy.live_index), - numReaders = config.database.readers ?: 4, + fullTextSearch = fullTextSearch, + dbOverride = a.opt("--db"), extraPragmas = extraPragmas, ) @@ -371,20 +389,24 @@ private fun serve(args: Array) { // Periodic query-planner statistics refresh (`PRAGMA optimize`). // Incremental and usually a no-op; failures are swallowed — a missed // refresh only means slightly staler planner stats until the next tick. + // SQLite-only: the `optimize()` PRAGMA has no meaning for other backends, + // so the loop only runs when the configured store is the SQLite one. val maintenanceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - config.database.optimize_interval_seconds?.let { secs -> - maintenanceScope.launch { - while (true) { - delay(secs * 1000) - try { - store.optimize() - } catch (e: CancellationException) { - throw e // shutdown cancelled us; don't swallow it - } catch (e: Exception) { - // A missed refresh only means slightly staler planner - // stats until the next tick — log and keep the loop. - // Errors (OOM, etc.) are NOT swallowed. - println("PRAGMA optimize failed: ${e.message}") + (store as? EventStore)?.let { sqlite -> + config.database.optimize_interval_seconds?.let { secs -> + maintenanceScope.launch { + while (true) { + delay(secs * 1000) + try { + sqlite.optimize() + } catch (e: CancellationException) { + throw e // shutdown cancelled us; don't swallow it + } catch (e: Exception) { + // A missed refresh only means slightly staler planner + // stats until the next tick — log and keep the loop. + // Errors (OOM, etc.) are NOT swallowed. + println("PRAGMA optimize failed: ${e.message}") + } } } } @@ -469,6 +491,14 @@ private class Args( private const val CONFIG_FLAG = "--config" private const val NO_VERIFY_FLAG = "--no-verify" private const val NO_SEARCH_FLAG = "--no-search" +private const val STORE_FLAG = "--store" + +/** + * Apply a `--store ` CLI override on top of the parsed + * `[database].backend`. `null` (flag absent) leaves the config untouched, so + * the TOML value — or its `"sqlite"` default — stands. + */ +private fun StaticConfig.withStoreOverride(backend: String?): StaticConfig = if (backend == null) this else copy(database = database.copy(backend = backend)) /** * Boolean flags that never take a value. Listing them explicitly is what lets a diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/StoreFactory.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/StoreFactory.kt new file mode 100644 index 0000000000..95615cb02c --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/StoreFactory.kt @@ -0,0 +1,177 @@ +/* + * 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.geode + +import com.vitorpamplona.geode.config.StaticConfig +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.IndexingStrategy +import java.lang.reflect.InvocationTargetException +import kotlin.io.path.Path + +/** + * Builds the relay's [IEventStore] from the parsed [StaticConfig]. + * + * `[database].backend` selects the implementation (see + * [StaticConfig.DatabaseSection.backend]); everything else the store needs + * — the advertised relay URL for NIP-62 scoping, the relay-tuned + * [relayIndexingStrategy], the SQLite deployment pragmas — is threaded + * through here so both the `serve` path and the `import`/`export` verbs open + * the *same* store from the *same* config. + * + * Two backends ship in quartz and are named by keyword; anything else is + * treated as a fully-qualified class name and loaded reflectively, which is + * what makes "pick any `IEventStore` implementation" true rather than a + * fixed menu of two. + */ +object StoreFactory { + /** `[database].backend` values that select the SQLite [EventStore] (the default). */ + val SQLITE_BACKEND_KEYWORDS = setOf("sqlite", "sqlite3", "") + + /** `[database].backend` values that select the filesystem [FsEventStore]. */ + val FS_BACKEND_KEYWORDS = setOf("fs", "file", "files", "filesystem") + + /** + * Open the configured store. + * + * @param config parsed operator config (owns `[database]`, `[negentropy]`). + * @param relay advertised relay URL, or `null` for an unscoped store + * (the `import`/`export` verbs pass `null`). + * @param fullTextSearch whether to build/maintain the NIP-50 index — + * already resolved from `[options].full_text_search` and `--no-search`. + * @param dbOverride the `--db` CLI flag when present; wins over + * `[database].file`. For SQLite it names the db file (`null` → + * in-memory); for `fs` it names the root directory. + * @param extraPragmas SQLite-only deployment pragmas (mmap/temp_store); + * ignored by non-SQLite backends. + */ + fun open( + config: StaticConfig, + relay: NormalizedRelayUrl?, + fullTextSearch: Boolean, + dbOverride: String? = null, + extraPragmas: List = emptyList(), + ): IEventStore { + val strategy = relayIndexingStrategy(fullTextSearch, config.negentropy.live_index) + val key = + config.database.backend + .trim() + .lowercase() + return when { + key in SQLITE_BACKEND_KEYWORDS -> + EventStore( + // `--db` wins; else the configured file unless the operator + // asked for an in-memory db (events vanish on restart). + dbName = dbOverride ?: config.database.file?.takeUnless { config.database.in_memory }, + relay = relay, + indexStrategy = strategy, + numReaders = config.database.readers ?: 4, + extraPragmas = extraPragmas, + ) + + key in FS_BACKEND_KEYWORDS -> { + // `in_memory` is a SQLite concept — the FS store always needs + // a directory. `--db` overrides the configured path. + val root = + dbOverride ?: config.database.file + ?: throw IllegalArgumentException( + "[database].backend = \"fs\" needs a directory: set [database].file " + + "(or pass --db

). The filesystem store cannot run in-memory.", + ) + FsEventStore( + root = Path(root), + indexingStrategy = strategy, + relay = relay, + ) + } + + else -> loadCustom(config.database.backend.trim(), relay, strategy) + } + } + + /** + * Reflectively instantiate a custom [IEventStore] named by its + * fully-qualified class name. The class must implement `IEventStore` + * and expose one of these public constructors, tried most-specific + * first: + * + * 1. `(NormalizedRelayUrl?, IndexingStrategy)` + * 2. `(NormalizedRelayUrl?)` + * 3. `()` + * + * Enough to hand a custom store the same relay scoping and index + * strategy the built-in backends receive, while still accepting a + * dependency-free no-arg store. + */ + private fun loadCustom( + className: String, + relay: NormalizedRelayUrl?, + strategy: IndexingStrategy, + ): IEventStore { + val clazz = + try { + Class.forName(className) + } catch (e: ClassNotFoundException) { + throw IllegalArgumentException( + "[database].backend = \"$className\" is neither a known backend " + + "(\"sqlite\", \"fs\") nor a class on the classpath. Give the fully-qualified " + + "name of an ${IEventStore::class.simpleName} implementation.", + e, + ) + } + require(IEventStore::class.java.isAssignableFrom(clazz)) { + "[database].backend class $className does not implement ${IEventStore::class.qualifiedName}." + } + + newInstance(clazz, arrayOf(NormalizedRelayUrl::class.java, IndexingStrategy::class.java), arrayOf(relay, strategy)) + ?.let { return it } + newInstance(clazz, arrayOf(NormalizedRelayUrl::class.java), arrayOf(relay)) + ?.let { return it } + newInstance(clazz, emptyArray(), emptyArray()) + ?.let { return it } + + throw IllegalArgumentException( + "$className has no supported public constructor. Provide one of: " + + "(NormalizedRelayUrl?, IndexingStrategy), (NormalizedRelayUrl?), or ().", + ) + } + + /** + * Invoke the `paramTypes` constructor with `args`, or return `null` if + * the class has no such (public) constructor. A throw from inside the + * constructor is unwrapped and rethrown — the store failed to build, + * which is not a signal to fall back to a different constructor shape. + */ + private fun newInstance( + clazz: Class<*>, + paramTypes: Array>, + args: Array, + ): IEventStore? = + try { + clazz.getConstructor(*paramTypes).newInstance(*args) as IEventStore + } catch (e: NoSuchMethodException) { + null + } catch (e: InvocationTargetException) { + throw e.cause ?: e + } +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index 1145a36a1c..dffe4e50e3 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -103,6 +103,31 @@ data class StaticConfig( ) data class DatabaseSection( + /** + * Which [com.vitorpamplona.quartz.nip01Core.store.IEventStore] + * implementation backs the relay. Recognised keywords (case- + * insensitive): + * + * - `"sqlite"` (default): quartz's SQLite-backed + * [com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore] — + * honours every `[database]` knob below ([in_memory]/[file], + * [readers], [mmap_size], …). The right choice for real traffic. + * - `"fs"`: quartz's filesystem + * [com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore], + * one JSON file per event under the directory named by [file] + * (which becomes a directory, not a db file, for this backend). + * Human-inspectable with `cat`/`jq`; the SQLite-only knobs are + * ignored. + * + * Any other value is treated as the fully-qualified class name of a + * custom `IEventStore` on the classpath, instantiated reflectively — + * see [com.vitorpamplona.geode.StoreFactory]. This is the "plug in + * any implementation" escape hatch: the class must implement + * `IEventStore` and expose one of the public constructors + * `(NormalizedRelayUrl?, IndexingStrategy)`, `(NormalizedRelayUrl?)`, + * or `()`. + */ + val backend: String = "sqlite", /** True keeps an in-memory SQLite db (default — events vanish on restart). */ val in_memory: Boolean = true, val file: String? = null, diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/StoreFactoryTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/StoreFactoryTest.kt new file mode 100644 index 0000000000..cf0939a96d --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/StoreFactoryTest.kt @@ -0,0 +1,122 @@ +/* + * 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.geode + +import com.vitorpamplona.geode.config.StaticConfig +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.IndexingStrategy +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * A stand-in "any other IEventStore" — the escape hatch's target. It only + * needs to implement [IEventStore] and expose the + * `(NormalizedRelayUrl?, IndexingStrategy)` constructor [StoreFactory] looks + * for; delegation to an in-memory [EventStore] keeps it a couple of lines. + */ +class FakeCustomStore( + relay: NormalizedRelayUrl?, + strategy: IndexingStrategy, +) : IEventStore by EventStore(dbName = null, relay = relay, indexStrategy = strategy) + +class StoreFactoryTest { + private fun config(toml: String) = StaticConfig.fromToml(toml) + + @Test + fun defaultBackendIsSqlite() { + val store = StoreFactory.open(config(""), relay = null, fullTextSearch = true) + store.use { assertTrue(it is EventStore, "default backend should be the SQLite EventStore") } + } + + @Test + fun sqliteKeywordSelectsEventStore() { + val store = StoreFactory.open(config("[database]\nbackend = \"SQLite\""), relay = null, fullTextSearch = true) + store.use { assertTrue(it is EventStore) } + } + + @Test + fun fsBackendSelectsFsEventStore() { + val dir = Files.createTempDirectory("geode-fs-store-").toFile() + try { + val toml = "[database]\nbackend = \"fs\"\nfile = \"${dir.absolutePath}\"" + val store = StoreFactory.open(config(toml), relay = null, fullTextSearch = true) + store.use { assertTrue(it is FsEventStore, "fs backend should be the filesystem FsEventStore") } + } finally { + dir.deleteRecursively() + } + } + + @Test + fun fsBackendViaDbOverride() { + val dir = Files.createTempDirectory("geode-fs-store-").toFile() + try { + // No `file` in the config — the --db override supplies the root. + val store = + StoreFactory.open( + config("[database]\nbackend = \"fs\""), + relay = null, + fullTextSearch = true, + dbOverride = dir.absolutePath, + ) + store.use { assertTrue(it is FsEventStore) } + } finally { + dir.deleteRecursively() + } + } + + @Test + fun fsBackendWithoutPathFailsLoud() { + assertFailsWith { + StoreFactory.open(config("[database]\nbackend = \"fs\""), relay = null, fullTextSearch = true) + } + } + + @Test + fun customClassNameIsLoadedReflectively() { + val toml = "[database]\nbackend = \"com.vitorpamplona.geode.FakeCustomStore\"" + val store = StoreFactory.open(config(toml), relay = null, fullTextSearch = true) + store.use { assertTrue(it is FakeCustomStore, "a FQCN backend should be instantiated reflectively") } + } + + @Test + fun unknownClassNameFailsLoud() { + assertFailsWith { + StoreFactory.open( + config("[database]\nbackend = \"com.example.NoSuchStore\""), + relay = null, + fullTextSearch = true, + ) + } + } + + @Test + fun classThatIsNotAnEventStoreFailsLoud() { + // java.lang.String resolves but does not implement IEventStore. + assertFailsWith { + StoreFactory.open(config("[database]\nbackend = \"java.lang.String\""), relay = null, fullTextSearch = true) + } + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt index d7698300d7..2ba0823441 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt @@ -77,6 +77,19 @@ class StaticConfigTest { assertEquals(null, d.database.optimize_interval_seconds) } + @Test + fun backendDefaultsToSqliteAndParses() { + // Unset → SQLite, so existing configs keep the current backend. + assertEquals("sqlite", StaticConfig.fromToml("").database.backend) + // Explicit values round-trip verbatim (case/keyword resolution is + // StoreFactory's job, not the parser's). + assertEquals("fs", StaticConfig.fromToml("[database]\nbackend = \"fs\"").database.backend) + assertEquals( + "com.example.MyStore", + StaticConfig.fromToml("[database]\nbackend = \"com.example.MyStore\"").database.backend, + ) + } + @Test fun mirrorSectionDefaultsToEmpty() { assertTrue(StaticConfig.fromToml("").mirror.isEmpty())