From a8209ea9041de5ac991e928c6eb50afdb697249e Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Wed, 1 Jul 2026 22:04:31 -0700 Subject: [PATCH 1/5] fix(ci): resolve pre-existing CI failures on main - Detekt: fix MultipleEmitters in DeviceSetupWizard (wrap in Column), ComposableParamOrder in SectionBadge (modifier before onClick), ComplexCondition in SettingsDialog (extract to local val) - Bazel Android: add ktoml dep missing from androidMain BUILD.bazel - Wasm/JS: move js() calls to top-level functions (Kotlin/Wasm restriction), replace onLeft with Either.Left pattern match to avoid missing import - SQLDelight: sync generated sources (idx_pages_section_id in create()) Co-Authored-By: Claude Sonnet 4.6 --- kmp/src/androidMain/kotlin/BUILD.bazel | 3 + .../stelekit/ui/components/SectionBadge.kt | 2 +- .../ui/components/settings/SettingsDialog.kt | 21 +++-- .../ui/onboarding/DeviceSetupWizard.kt | 82 ++++++++++--------- .../stelekit/db/SteleDatabaseQueries.kt | 8 +- .../stelekit/db/kmp/SteleDatabaseImpl.kt | 1 + .../stelekit/sync/WasmSectionSyncService.kt | 39 +++++---- 7 files changed, 87 insertions(+), 69 deletions(-) diff --git a/kmp/src/androidMain/kotlin/BUILD.bazel b/kmp/src/androidMain/kotlin/BUILD.bazel index d7b2c25b..9a663e8e 100644 --- a/kmp/src/androidMain/kotlin/BUILD.bazel +++ b/kmp/src/androidMain/kotlin/BUILD.bazel @@ -97,6 +97,9 @@ kt_android_library( "@maven//:io_opentelemetry_opentelemetry_sdk", "@maven//:io_opentelemetry_opentelemetry_exporter_logging", + # ktoml — TOML parsing for .stele-sections manifest (from commonMain) + "@maven//:com_akuleshov7_ktoml_core_jvm", + # Ksoup (from commonMain) "@maven//:com_fleeksoft_ksoup_ksoup_jvm", diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SectionBadge.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SectionBadge.kt index 62db9923..65c2ab1c 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SectionBadge.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SectionBadge.kt @@ -28,8 +28,8 @@ import dev.stapler.stelekit.sections.SectionDefinition @Composable fun SectionBadge( section: SectionDefinition, - onClick: (() -> Unit)? = null, modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, ) { val dotColor = remember(section.color) { section.color?.let { diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/settings/SettingsDialog.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/settings/SettingsDialog.kt index 2ff5021f..2b9aa7a3 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/settings/SettingsDialog.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/settings/SettingsDialog.kt @@ -214,15 +214,18 @@ fun SettingsDialog( onLibsqlDriverToggle = onLibsqlDriverToggle, ) } - SettingsCategory.SECTIONS -> if (sectionManifest != null && - onCreateSection != null && onRenameSection != null && onDeleteSection != null - ) { - SectionsSettings( - manifest = sectionManifest, - onCreateSection = onCreateSection, - onRenameSection = onRenameSection, - onDeleteSection = onDeleteSection, - ) + SettingsCategory.SECTIONS -> { + val canShowSections = sectionManifest != null && + onCreateSection != null && onRenameSection != null && + onDeleteSection != null + if (canShowSections) { + SectionsSettings( + manifest = sectionManifest!!, + onCreateSection = onCreateSection!!, + onRenameSection = onRenameSection!!, + onDeleteSection = onDeleteSection!!, + ) + } } SettingsCategory.DEVICE_SUBSCRIPTIONS -> if (sectionManifest != null && onToggleSectionState != null diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/onboarding/DeviceSetupWizard.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/onboarding/DeviceSetupWizard.kt index daec2f63..4022deeb 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/onboarding/DeviceSetupWizard.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/onboarding/DeviceSetupWizard.kt @@ -108,16 +108,18 @@ private fun ModeSelectionContent( onPersonalMode: () -> Unit, onCustomMode: () -> Unit, ) { - Text("How will you use SteleKit on this device?", style = MaterialTheme.typography.bodyMedium) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedButton(onClick = onWorkMode, modifier = Modifier.fillMaxWidth()) { - Text("Work device — primary section only") - } - OutlinedButton(onClick = onPersonalMode, modifier = Modifier.fillMaxWidth()) { - Text("Personal device — all sections active") - } - OutlinedButton(onClick = onCustomMode, modifier = Modifier.fillMaxWidth()) { - Text("Custom") + Column { + Text("How will you use SteleKit on this device?", style = MaterialTheme.typography.bodyMedium) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton(onClick = onWorkMode, modifier = Modifier.fillMaxWidth()) { + Text("Work device — primary section only") + } + OutlinedButton(onClick = onPersonalMode, modifier = Modifier.fillMaxWidth()) { + Text("Personal device — all sections active") + } + OutlinedButton(onClick = onCustomMode, modifier = Modifier.fillMaxWidth()) { + Text("Custom") + } } } @@ -150,39 +152,41 @@ private fun CustomModeContent( customDefaultSection: String, onDefaultChange: (String) -> Unit, ) { - Text("Section access:", style = MaterialTheme.typography.bodyMedium) - sections.forEach { section -> - val state = customStates[section.id] ?: SectionState.ACTIVE - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(section.displayName, modifier = Modifier.weight(1f)) - TextButton(onClick = { - val next = when (state) { - SectionState.ACTIVE -> SectionState.HIDDEN - SectionState.HIDDEN -> SectionState.REMOVED - SectionState.REMOVED -> SectionState.ACTIVE + Column { + Text("Section access:", style = MaterialTheme.typography.bodyMedium) + sections.forEach { section -> + val state = customStates[section.id] ?: SectionState.ACTIVE + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(section.displayName, modifier = Modifier.weight(1f)) + TextButton(onClick = { + val next = when (state) { + SectionState.ACTIVE -> SectionState.HIDDEN + SectionState.HIDDEN -> SectionState.REMOVED + SectionState.REMOVED -> SectionState.ACTIVE + } + onStateChange(section.id, next) + }) { + Text(state.name.lowercase().replaceFirstChar { it.uppercase() }) } - onStateChange(section.id, next) - }) { - Text(state.name.lowercase().replaceFirstChar { it.uppercase() }) } } - } - Spacer(modifier = Modifier.height(8.dp)) - Text("Default section (for new pages):", style = MaterialTheme.typography.bodyMedium) - Row(verticalAlignment = Alignment.CenterVertically) { - RadioButton(selected = customDefaultSection.isEmpty(), onClick = { onDefaultChange("") }) - Text("Global", style = MaterialTheme.typography.bodyLarge) - } - sections.forEach { section -> + Spacer(modifier = Modifier.height(8.dp)) + Text("Default section (for new pages):", style = MaterialTheme.typography.bodyMedium) Row(verticalAlignment = Alignment.CenterVertically) { - RadioButton( - selected = customDefaultSection == section.id, - onClick = { onDefaultChange(section.id) }, - ) - Text(section.displayName, style = MaterialTheme.typography.bodyLarge) + RadioButton(selected = customDefaultSection.isEmpty(), onClick = { onDefaultChange("") }) + Text("Global", style = MaterialTheme.typography.bodyLarge) + } + sections.forEach { section -> + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton( + selected = customDefaultSection == section.id, + onClick = { onDefaultChange(section.id) }, + ) + Text(section.displayName, style = MaterialTheme.typography.bodyLarge) + } } } } diff --git a/kmp/src/generated/sqldelight/dev/stapler/stelekit/db/SteleDatabaseQueries.kt b/kmp/src/generated/sqldelight/dev/stapler/stelekit/db/SteleDatabaseQueries.kt index bc598c95..815bd0e3 100644 --- a/kmp/src/generated/sqldelight/dev/stapler/stelekit/db/SteleDatabaseQueries.kt +++ b/kmp/src/generated/sqldelight/dev/stapler/stelekit/db/SteleDatabaseQueries.kt @@ -991,7 +991,11 @@ public class SteleDatabaseQueries( ) } - public fun selectUnloadedPagesBySection(section_id: Collection, value_: Long, value__: Long): Query = selectUnloadedPagesBySection(section_id, value_, value__, ::Pages) + public fun selectUnloadedPagesBySection( + section_id: Collection, + value_: Long, + value__: Long, + ): Query = selectUnloadedPagesBySection(section_id, value_, value__, ::Pages) public fun countUnloadedPages(): Query = Query(-58_485_640, arrayOf("pages"), driver, "SteleDatabase.sq", "countUnloadedPages", "SELECT COUNT(*) FROM pages WHERE is_content_loaded = 0") { cursor -> cursor.getLong(0)!! @@ -5882,7 +5886,7 @@ public class SteleDatabaseQueries( override fun execute(mapper: (SqlCursor) -> QueryResult): QueryResult { val section_idIndexes = createArguments(count = section_id.size) - return driver.executeQuery(null, """SELECT pages.uuid, pages.name, pages.namespace, pages.file_path, pages.created_at, pages.updated_at, pages.properties, pages.version, pages.is_favorite, pages.is_journal, pages.journal_date, pages.is_content_loaded, pages.backlink_count, pages.section_id FROM pages WHERE is_content_loaded = 0 AND section_id IN $section_idIndexes ORDER BY uuid LIMIT ? OFFSET ?""", mapper, section_id.size + 2) { + return driver.executeQuery(null, """SELECT pages.uuid, pages.name, pages.namespace, pages.file_path, pages.created_at, pages.updated_at, pages.properties, pages.version, pages.is_favorite, pages.is_journal, pages.journal_date, pages.is_content_loaded, pages.backlink_count, pages.section_id FROM pages WHERE is_content_loaded = 0 AND section_id IN $section_idIndexes ORDER BY uuid LIMIT ? OFFSET ?""", mapper, 2 + section_id.size) { var parameterIndex = 0 section_id.forEach { section_id_ -> bindString(parameterIndex++, section_id_) diff --git a/kmp/src/generated/sqldelight/dev/stapler/stelekit/db/kmp/SteleDatabaseImpl.kt b/kmp/src/generated/sqldelight/dev/stapler/stelekit/db/kmp/SteleDatabaseImpl.kt index c04b6700..1b0216fd 100644 --- a/kmp/src/generated/sqldelight/dev/stapler/stelekit/db/kmp/SteleDatabaseImpl.kt +++ b/kmp/src/generated/sqldelight/dev/stapler/stelekit/db/kmp/SteleDatabaseImpl.kt @@ -266,6 +266,7 @@ private class SteleDatabaseImpl( driver.execute(null, "CREATE INDEX idx_pages_created_at ON pages(created_at DESC)", 0).await() driver.execute(null, "CREATE INDEX idx_pages_favorite ON pages(name) WHERE is_favorite = 1", 0).await() driver.execute(null, "CREATE INDEX idx_pages_journal_section ON pages(is_journal, journal_date, section_id)", 0).await() + driver.execute(null, "CREATE INDEX idx_pages_section_id ON pages(section_id, name)", 0).await() driver.execute(null, "CREATE INDEX idx_changelog_graph_status ON migration_changelog(graph_id, status)", 0).await() driver.execute(null, "CREATE INDEX idx_changelog_applied_at ON migration_changelog(graph_id, applied_at)", 0).await() driver.execute(null, """ diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/sync/WasmSectionSyncService.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/sync/WasmSectionSyncService.kt index 1e29a38f..519dea4f 100644 --- a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/sync/WasmSectionSyncService.kt +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/sync/WasmSectionSyncService.kt @@ -1,6 +1,6 @@ package dev.stapler.stelekit.sync -import arrow.core.onLeft +import arrow.core.Either import dev.stapler.stelekit.logging.Logger import dev.stapler.stelekit.model.Page import dev.stapler.stelekit.model.PageUuid @@ -13,6 +13,22 @@ import kotlinx.coroutines.await import kotlinx.coroutines.delay import kotlin.time.Clock +// js() calls must be top-level functions in Kotlin/Wasm — not inside a class or companion object. +private fun jsFetchWithToken(url: String, token: String): kotlin.js.Promise = + js("""fetch(url, { headers: { 'Authorization': 'Bearer ' + token, 'Accept': 'application/vnd.github+json' } })""") + +private fun jsFetchAnon(url: String): kotlin.js.Promise = + js("""fetch(url, { headers: { 'Accept': 'application/vnd.github+json' } })""") + +private fun jsFetch(url: String, token: String?): kotlin.js.Promise = + if (token != null) jsFetchWithToken(url, token) else jsFetchAnon(url) + +private fun jsResponseStatus(response: JsAny): Int = js("response.status | 0") +private fun jsResponseHeader(response: JsAny, name: String): String? = + js("response.headers.get(name) || null") +private fun jsResponseText(response: JsAny): kotlin.js.Promise = js("response.text()") +private fun jsStringValue(v: JsAny): String = js("String(v)") + /** * WASM-only service: fetches a GitHub repo tree for a section and inserts INDEX_ONLY stub pages. * @@ -55,8 +71,10 @@ class WasmSectionSyncService(private val pageRepository: PageRepository) { isContentLoaded = false, sectionId = section.id, ) - pageRepository.savePage(stubPage) - .onLeft { logger.error("Failed to save stub for $fullPath: ${it.message}") } + val result = pageRepository.savePage(stubPage) + if (result is Either.Left) { + logger.error("Failed to save stub for $fullPath: ${result.value.message}") + } } } @@ -90,21 +108,6 @@ class WasmSectionSyncService(private val pageRepository: PageRepository) { } } - private fun jsFetchWithToken(url: String, token: String): kotlin.js.Promise = - js("""fetch(url, { headers: { 'Authorization': 'Bearer ' + token, 'Accept': 'application/vnd.github+json' } })""") - - private fun jsFetchAnon(url: String): kotlin.js.Promise = - js("""fetch(url, { headers: { 'Accept': 'application/vnd.github+json' } })""") - - private fun jsFetch(url: String, token: String?): kotlin.js.Promise = - if (token != null) jsFetchWithToken(url, token) else jsFetchAnon(url) - - private fun jsResponseStatus(response: JsAny): Int = js("response.status | 0") - private fun jsResponseHeader(response: JsAny, name: String): String? = - js("response.headers.get(name) || null") - private fun jsResponseText(response: JsAny): kotlin.js.Promise = js("response.text()") - private fun jsStringValue(v: JsAny): String = js("String(v)") - private fun extractTreePaths(json: String): List { val results = mutableListOf() var i = json.indexOf("\"path\"") From fec838dcce02c33b4c656d0cafc26285bb4dd5c3 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Wed, 1 Jul 2026 22:33:24 -0700 Subject: [PATCH 2/5] fix(wasm): move ktoml out of commonMain to fix wasm-opt failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ktoml 0.7.1 generates WASM bytecode that fails binaryen wasm-opt validation during the production bundle optimization step. Move ktoml to jvmCommonMain + iosMain and introduce an expect/actual split: - jvmCommonMain/iosMain: actual uses ktoml for real TOML parsing - wasmJsMain: actual returns null (SectionManifestParser falls back to empty SectionManifest — no file system in the browser anyway) Co-Authored-By: Claude Sonnet 4.6 --- kmp/build.gradle.kts | 9 +++++++-- .../stelekit/sections/SectionManifestParser.kt | 11 +++++------ .../sections/SectionManifestTomlDecoder.ios.kt | 10 ++++++++++ .../sections/SectionManifestTomlDecoder.jvm.kt | 10 ++++++++++ .../sections/SectionManifestTomlDecoder.js.kt | 5 +++++ 5 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 kmp/src/iosMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.ios.kt create mode 100644 kmp/src/jvmCommonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.jvm.kt create mode 100644 kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.js.kt diff --git a/kmp/build.gradle.kts b/kmp/build.gradle.kts index 0cf7b12a..c05ff9ef 100644 --- a/kmp/build.gradle.kts +++ b/kmp/build.gradle.kts @@ -93,8 +93,6 @@ kotlin { // Okio — cross-platform file I/O for asset management implementation("com.squareup.okio:okio:3.17.0") - // ktoml — TOML parsing for .stele-sections manifest - implementation("com.akuleshov7:ktoml-core:0.7.1") } } @@ -103,6 +101,9 @@ kotlin { dependencies { // OpenTelemetry API — JVM/Android only (not available for wasmJs) implementation("io.opentelemetry:opentelemetry-api:1.43.0") + // ktoml — TOML parsing for .stele-sections manifest (excluded from wasmJs: + // ktoml generates WASM bytecode that fails wasm-opt validation) + implementation("com.akuleshov7:ktoml-core:0.7.1") } } @@ -313,6 +314,10 @@ kotlin { // Kable — Kotlin coroutine BLE for iOS/Apple targets (CoreBluetooth wrapper) implementation("com.juul.kable:core:0.32.0") + + // ktoml — TOML parsing for .stele-sections manifest (actual impl for iOS; + // excluded from commonMain to avoid invalid WASM bytecode on wasmJs target) + implementation("com.akuleshov7:ktoml-core:0.7.1") } } diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestParser.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestParser.kt index 40f1954d..0a4e8e6f 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestParser.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestParser.kt @@ -3,14 +3,9 @@ package dev.stapler.stelekit.sections import arrow.core.Either import arrow.core.left import arrow.core.right -import com.akuleshov7.ktoml.Toml -import com.akuleshov7.ktoml.TomlInputConfig import dev.stapler.stelekit.error.DomainError import dev.stapler.stelekit.platform.FileSystem import kotlinx.coroutines.CancellationException -import kotlinx.serialization.serializer - -internal val sectionToml = Toml(inputConfig = TomlInputConfig(ignoreUnknownNames = true)) class SectionManifestParser(private val fileSystem: FileSystem) { @@ -23,7 +18,7 @@ class SectionManifestParser(private val fileSystem: FileSystem) { ?: return DomainError.FileSystemError.ReadFailed(path, "could not read manifest").left() return try { - sectionToml.decodeFromString(serializer(), content).right() + (decodeSectionManifestToml(content) ?: SectionManifest()).right() } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -31,3 +26,7 @@ class SectionManifestParser(private val fileSystem: FileSystem) { } } } + +// ktoml doesn't support Kotlin/Wasm — platform actuals provide real parsing on JVM/iOS, +// null on WASM (caller falls back to empty SectionManifest). +internal expect fun decodeSectionManifestToml(content: String): SectionManifest? diff --git a/kmp/src/iosMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.ios.kt b/kmp/src/iosMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.ios.kt new file mode 100644 index 00000000..8a22bf84 --- /dev/null +++ b/kmp/src/iosMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.ios.kt @@ -0,0 +1,10 @@ +package dev.stapler.stelekit.sections + +import com.akuleshov7.ktoml.Toml +import com.akuleshov7.ktoml.TomlInputConfig +import kotlinx.serialization.serializer + +private val sectionToml = Toml(inputConfig = TomlInputConfig(ignoreUnknownNames = true)) + +internal actual fun decodeSectionManifestToml(content: String): SectionManifest? = + sectionToml.decodeFromString(serializer(), content) diff --git a/kmp/src/jvmCommonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.jvm.kt b/kmp/src/jvmCommonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.jvm.kt new file mode 100644 index 00000000..8a22bf84 --- /dev/null +++ b/kmp/src/jvmCommonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.jvm.kt @@ -0,0 +1,10 @@ +package dev.stapler.stelekit.sections + +import com.akuleshov7.ktoml.Toml +import com.akuleshov7.ktoml.TomlInputConfig +import kotlinx.serialization.serializer + +private val sectionToml = Toml(inputConfig = TomlInputConfig(ignoreUnknownNames = true)) + +internal actual fun decodeSectionManifestToml(content: String): SectionManifest? = + sectionToml.decodeFromString(serializer(), content) diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.js.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.js.kt new file mode 100644 index 00000000..fc704fae --- /dev/null +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.js.kt @@ -0,0 +1,5 @@ +package dev.stapler.stelekit.sections + +// ktoml generates invalid WASM bytecode (wasm-opt validation failure) — return null +// so SectionManifestParser falls back to an empty SectionManifest in the browser. +internal actual fun decodeSectionManifestToml(content: String): SectionManifest? = null From 8b78dad2b7baf35c6aa77483d0b63f319a343819 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Wed, 1 Jul 2026 22:41:33 -0700 Subject: [PATCH 3/5] fix(sections): add encodeSectionManifestToml expect/actual for SectionManifestWriter SectionManifestWriter.kt (commonMain) referenced sectionToml directly, which was moved to jvmCommonMain in the ktoml WASM fix. Apply the same expect/actual split: JVM+iOS actuals delegate to ktoml, WASM stub throws (caught by the surrounding Either error handler). Co-Authored-By: Claude Sonnet 4.6 --- .../dev/stapler/stelekit/sections/SectionManifestParser.kt | 5 +++-- .../dev/stapler/stelekit/sections/SectionManifestWriter.kt | 4 +--- .../stelekit/sections/SectionManifestTomlDecoder.ios.kt | 3 +++ .../stelekit/sections/SectionManifestTomlDecoder.jvm.kt | 3 +++ .../stelekit/sections/SectionManifestTomlDecoder.js.kt | 3 +++ 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestParser.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestParser.kt index 0a4e8e6f..ab01384f 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestParser.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestParser.kt @@ -27,6 +27,7 @@ class SectionManifestParser(private val fileSystem: FileSystem) { } } -// ktoml doesn't support Kotlin/Wasm — platform actuals provide real parsing on JVM/iOS, -// null on WASM (caller falls back to empty SectionManifest). +// ktoml doesn't support Kotlin/Wasm — platform actuals provide real parsing/writing on JVM/iOS, +// stubs on WASM (parser falls back to empty SectionManifest; writer catches the exception). internal expect fun decodeSectionManifestToml(content: String): SectionManifest? +internal expect fun encodeSectionManifestToml(manifest: SectionManifest): String diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestWriter.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestWriter.kt index bb7146ff..5055fdb1 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestWriter.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestWriter.kt @@ -6,15 +6,13 @@ import arrow.core.right import dev.stapler.stelekit.error.DomainError import dev.stapler.stelekit.platform.FileSystem import kotlinx.coroutines.CancellationException -import kotlinx.serialization.serializer - class SectionManifestWriter(private val fileSystem: FileSystem) { fun write(graphPath: String, manifest: SectionManifest): Either { val base = if (graphPath.endsWith("/")) graphPath else "$graphPath/" val path = "$base${SectionManifest.FILENAME}" return try { - val content = sectionToml.encodeToString(serializer(), manifest) + val content = encodeSectionManifestToml(manifest) if (fileSystem.writeFile(path, content)) Unit.right() else DomainError.FileSystemError.WriteFailed(path, "write returned false").left() } catch (e: CancellationException) { diff --git a/kmp/src/iosMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.ios.kt b/kmp/src/iosMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.ios.kt index 8a22bf84..5ac71d17 100644 --- a/kmp/src/iosMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.ios.kt +++ b/kmp/src/iosMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.ios.kt @@ -8,3 +8,6 @@ private val sectionToml = Toml(inputConfig = TomlInputConfig(ignoreUnknownNames internal actual fun decodeSectionManifestToml(content: String): SectionManifest? = sectionToml.decodeFromString(serializer(), content) + +internal actual fun encodeSectionManifestToml(manifest: SectionManifest): String = + sectionToml.encodeToString(serializer(), manifest) diff --git a/kmp/src/jvmCommonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.jvm.kt b/kmp/src/jvmCommonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.jvm.kt index 8a22bf84..5ac71d17 100644 --- a/kmp/src/jvmCommonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.jvm.kt +++ b/kmp/src/jvmCommonMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.jvm.kt @@ -8,3 +8,6 @@ private val sectionToml = Toml(inputConfig = TomlInputConfig(ignoreUnknownNames internal actual fun decodeSectionManifestToml(content: String): SectionManifest? = sectionToml.decodeFromString(serializer(), content) + +internal actual fun encodeSectionManifestToml(manifest: SectionManifest): String = + sectionToml.encodeToString(serializer(), manifest) diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.js.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.js.kt index fc704fae..ced0f639 100644 --- a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.js.kt +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/sections/SectionManifestTomlDecoder.js.kt @@ -3,3 +3,6 @@ package dev.stapler.stelekit.sections // ktoml generates invalid WASM bytecode (wasm-opt validation failure) — return null // so SectionManifestParser falls back to an empty SectionManifest in the browser. internal actual fun decodeSectionManifestToml(content: String): SectionManifest? = null + +internal actual fun encodeSectionManifestToml(manifest: SectionManifest): String = + throw UnsupportedOperationException("TOML encoding not supported on WASM") From 29a13d373392d94ca66e7709aed2f12acf25db24 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Thu, 2 Jul 2026 07:39:15 -0700 Subject: [PATCH 4/5] perf(db): skip ANALYZE on warm starts when sqlite_stat1 already has stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ANALYZE blocks + ANALYZE pages ran unconditionally every startup (~50ms each on Android). Gate them on sqlite_stat1 having no entry for the table — the only case that needs explicit ANALYZE is the second startup after a fresh install (when the analyze_blocks migration ran on an empty table and wrote no stats). Warm starts now run 2× cheap SELECT instead. PRAGMA optimize continues to run unconditionally to handle stale stats for all other tables. Co-Authored-By: Claude Sonnet 4.6 --- .../dev/stapler/stelekit/db/MigrationRunner.kt | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/MigrationRunner.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/MigrationRunner.kt index 4e0df451..d0157479 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/MigrationRunner.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/MigrationRunner.kt @@ -828,12 +828,24 @@ object MigrationRunner { // driver throws when execSQL is called for result-returning statements. It is set in // ANDROID_PRAGMAS via the rawQuery path (DriverFactory.android.kt) and in // buildMainDbConnectionProps() for JVM so every pool connection inherits it. - driver.execute(null, "ANALYZE blocks", 0).await() - driver.execute(null, "ANALYZE pages", 0).await() - // PRAGMA optimize is still useful for other tables we don't explicitly analyze above. + // ponytail: only ANALYZE when stats are absent; PRAGMA optimize handles stale stats + if (!hasStats(driver, "blocks")) driver.execute(null, "ANALYZE blocks", 0).await() + if (!hasStats(driver, "pages")) driver.execute(null, "ANALYZE pages", 0).await() driver.execute(null, "PRAGMA optimize", 0).await() } + // Returns false if sqlite_stat1 doesn't exist yet or has no row for [table]. + private suspend fun hasStats(driver: SqlDriver, table: String): Boolean = try { + driver.executeQuery( + identifier = null, + sql = "SELECT count(*) FROM sqlite_stat1 WHERE tbl = '$table'", + mapper = { cursor -> cursor.next(); QueryResult.Value((cursor.getLong(0) ?: 0L) > 0L) }, + parameters = 0 + ).await() + } catch (_: Exception) { + false + } + internal suspend fun applyAll(driver: SqlDriver, migrations: List) { // Bootstrap the tracking table — must succeed before anything else. driver.execute( From 51ad9c35fdff9599514e5eb131a9d2dca69d1c80 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Thu, 2 Jul 2026 07:40:56 -0700 Subject: [PATCH 5/5] debug(init): add elapsed-time logs to switchGraph init sequence Logs ms elapsed at each major step (preFlightJob, createRepositorySet, UuidMigration, content migrations) so the overlay shows where init time is actually spent. Co-Authored-By: Claude Sonnet 4.6 --- .../kotlin/dev/stapler/stelekit/db/GraphManager.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphManager.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphManager.kt index bdcf42c1..775dcac2 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphManager.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/GraphManager.kt @@ -378,7 +378,11 @@ class GraphManager( // never hangs permanently. graphScope.launch(PlatformDispatcher.IO) { try { + val t0 = kotlin.time.Clock.System.now().toEpochMilliseconds() + fun elapsed() = kotlin.time.Clock.System.now().toEpochMilliseconds() - t0 + preFlightJob?.await() + logger.info("init[${elapsed()}ms]: preFlightJob done") val dbUrl = driverFactory.getDatabaseUrl(id) val factory = dev.stapler.stelekit.repository.RepositoryFactoryImpl(driverFactory, dbUrl, graphId = id) @@ -396,6 +400,7 @@ class GraphManager( appVersion = deviceInfo?.appVersion ?: "unknown", platform = deviceInfo?.platform ?: "unknown", ) + logger.info("init[${elapsed()}ms]: createRepositorySet done") currentFactory = factory _activeRepositorySet.value = repoSet @@ -404,6 +409,7 @@ class GraphManager( if (writeActor != null) { val db = factory.steleDatabase() UuidMigration(writeActor).runIfNeeded(db) + logger.info("init[${elapsed()}ms]: UuidMigration done") try { MigrationRunner( registry = MigrationRegistry, @@ -417,6 +423,7 @@ class GraphManager( } catch (e: MigrationTamperedError) { logger.error("MigrationRunner: tampered migration detected for graph $id", e) } + logger.info("init[${elapsed()}ms]: content migrations done") } } } catch (e: CancellationException) {