Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions kmp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand All @@ -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")
}
}

Expand Down Expand Up @@ -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")
}
}

Expand Down
3 changes: 3 additions & 0 deletions kmp/src/androidMain/kotlin/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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,
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment on lines +831 to 834
}

// 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()
Comment on lines +839 to +844
} catch (_: Exception) {
false
}

internal suspend fun applyAll(driver: SqlDriver, migrations: List<Migration>) {
// Bootstrap the tracking table — must succeed before anything else.
driver.execute(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Expand All @@ -23,11 +18,16 @@ class SectionManifestParser(private val fileSystem: FileSystem) {
?: return DomainError.FileSystemError.ReadFailed(path, "could not read manifest").left()

return try {
sectionToml.decodeFromString(serializer<SectionManifest>(), content).right()
(decodeSectionManifestToml(content) ?: SectionManifest()).right()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
DomainError.ParseError.InvalidSyntax("${SectionManifest.FILENAME}: ${e.message ?: "invalid TOML"}").left()
}
}
}

// 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
Original file line number Diff line number Diff line change
Expand Up @@ -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<DomainError, Unit> {
val base = if (graphPath.endsWith("/")) graphPath else "$graphPath/"
val path = "$base${SectionManifest.FILENAME}"
return try {
val content = sectionToml.encodeToString(serializer<SectionManifest>(), manifest)
val content = encodeSectionManifestToml(manifest)
if (fileSystem.writeFile(path, content)) Unit.right()
else DomainError.FileSystemError.WriteFailed(path, "write returned false").left()
} catch (e: CancellationException) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}

Expand Down Expand Up @@ -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)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -991,7 +991,11 @@ public class SteleDatabaseQueries(
)
}

public fun selectUnloadedPagesBySection(section_id: Collection<String>, value_: Long, value__: Long): Query<Pages> = selectUnloadedPagesBySection(section_id, value_, value__, ::Pages)
public fun selectUnloadedPagesBySection(
section_id: Collection<String>,
value_: Long,
value__: Long,
): Query<Pages> = selectUnloadedPagesBySection(section_id, value_, value__, ::Pages)

public fun countUnloadedPages(): Query<Long> = Query(-58_485_640, arrayOf("pages"), driver, "SteleDatabase.sq", "countUnloadedPages", "SELECT COUNT(*) FROM pages WHERE is_content_loaded = 0") { cursor ->
cursor.getLong(0)!!
Expand Down Expand Up @@ -5882,7 +5886,7 @@ public class SteleDatabaseQueries(

override fun <R> execute(mapper: (SqlCursor) -> QueryResult<R>): QueryResult<R> {
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_)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, """
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
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<SectionManifest>(), content)

internal actual fun encodeSectionManifestToml(manifest: SectionManifest): String =
sectionToml.encodeToString(serializer<SectionManifest>(), manifest)
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
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<SectionManifest>(), content)

internal actual fun encodeSectionManifestToml(manifest: SectionManifest): String =
sectionToml.encodeToString(serializer<SectionManifest>(), manifest)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
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")
Loading
Loading