From 778324c6ae9d091df3d0d1532c4566d36e094529 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Thu, 7 May 2026 06:00:21 -0700 Subject: [PATCH 1/8] feat(web): implement OPFS-backed SQLite driver and local dev script Replaces the browser demo's hard-coded IN_MEMORY + DemoFileSystem stack with a real durable storage layer using @sqlite.org/sqlite-wasm and the OPFS SAH-pool VFS, so graph data persists across browser sessions. - Add WasmOpfsSqlDriver: full SqlDriver backed by a JS Web Worker running @sqlite.org/sqlite-wasm with opfs-sahpool; all ops return QueryResult.AsyncValue - Add SqliteWorkerInterop.kt, JsBindCollector, JsRowCursor supporting files - Add sqlite-stelekit-worker.js: module worker initialising opfs-sahpool, falls back to :memory: if OPFS is unavailable (private browsing) - Add OpfsInterop.kt: suspend helpers for OPFS directory walk, file read/write - Rewrite PlatformFileSystem (wasmJs): in-memory cache pre-loaded from OPFS at startup; writes are cache-first with async OPFS write-through - Rewrite browser/Main.kt: async OPFS init outside Compose tree; sets window.__stelekit_ready for Playwright test synchronisation - Update DriverFactory.js.kt: createDriverAsync() caches the pre-init driver so that createDriver() (called by RepositoryFactory) returns it - Make MigrationRunner.applyAll() suspend; wrap JVM/Android call sites in runBlocking to stay backwards-compatible - Enable generateAsync = true in SQLDelight to support async SqlDriver - Add @sqlite.org/sqlite-wasm@3.46.1 npm dep to wasmJsMain - Add scripts/serve-web.sh: single command to build and preview locally - Extend e2e/tests/demo.spec.ts: OPFS persistence test + beforeEach cleanup Note: after the first build, verify sqlite-stelekit-worker.js and sqlite3.wasm appear in kmp/build/dist/wasmJs/productionExecutable/. If the worker path differs, update workerScriptPath in DriverFactory.js.kt. Co-Authored-By: Claude Sonnet 4.6 --- e2e/tests/demo.spec.ts | 45 ++++++++ kmp/build.gradle.kts | 3 +- .../stelekit/db/DriverFactory.android.kt | 3 +- ...rFactory.android.kt.tmp.4573.1775983611619 | 68 ----------- .../stapler/stelekit/db/MigrationRunner.kt | 24 ++-- .../stapler/stelekit/db/DriverFactory.jvm.kt | 5 +- .../dev/stapler/stelekit/browser/Main.kt | 49 +++++--- .../stapler/stelekit/db/DriverFactory.js.kt | 21 +++- .../stapler/stelekit/db/JsBindCollector.kt | 25 +++++ .../dev/stapler/stelekit/db/JsRowCursor.kt | 58 ++++++++++ .../stelekit/db/SqliteWorkerInterop.kt | 80 +++++++++++++ .../stapler/stelekit/db/WasmOpfsSqlDriver.kt | 106 ++++++++++++++++++ .../stapler/stelekit/platform/OpfsInterop.kt | 79 +++++++++++++ .../stelekit/platform/PlatformFileSystem.kt | 77 +++++++++++-- .../resources/sqlite-stelekit-worker.js | 47 ++++++++ scripts/serve-web.sh | 14 +++ 16 files changed, 593 insertions(+), 111 deletions(-) delete mode 100644 kmp/src/androidMain/kotlin/dev/stapler/stelekit/db/DriverFactory.android.kt.tmp.4573.1775983611619 create mode 100644 kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/JsBindCollector.kt create mode 100644 kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/JsRowCursor.kt create mode 100644 kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/SqliteWorkerInterop.kt create mode 100644 kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/WasmOpfsSqlDriver.kt create mode 100644 kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/OpfsInterop.kt create mode 100644 kmp/src/wasmJsMain/resources/sqlite-stelekit-worker.js create mode 100755 scripts/serve-web.sh diff --git a/e2e/tests/demo.spec.ts b/e2e/tests/demo.spec.ts index 1ffadb9d..18e8d083 100644 --- a/e2e/tests/demo.spec.ts +++ b/e2e/tests/demo.spec.ts @@ -4,6 +4,19 @@ import { test, expect } from '@playwright/test'; // Assertions here verify that the WASM binary compiles to something that // actually runs in a browser, not just that the build directory exists. +test.beforeEach(async ({ page }) => { + // Clear OPFS stelekit directory before each test to prevent inter-test bleed + await page.goto('/'); + await page.evaluate(async () => { + try { + const root = await navigator.storage.getDirectory(); + await root.removeEntry('stelekit', { recursive: true }); + } catch { + // Directory may not exist on first run + } + }); +}); + test('SteleKit WASM demo: canvas initializes and Compose paints', async ({ page }) => { const errors: string[] = []; page.on('pageerror', err => { @@ -62,3 +75,35 @@ test('SteleKit WASM demo: canvas initializes and Compose paints', async ({ page // Step 4: no uncaught JS exceptions during startup. expect(errors, `Uncaught JS errors: ${errors.join(' | ')}`).toHaveLength(0); }); + +test('SteleKit OPFS: data persists across page reload', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', err => errors.push(err.message)); + + await page.goto('/'); + + await page.waitForFunction( + () => (window as any).__stelekit_ready === true, + { timeout: 30_000 }, + ); + + await page.reload(); + + await page.waitForFunction( + () => (window as any).__stelekit_ready === true, + { timeout: 30_000 }, + ); + + const hasOpfsData = await page.evaluate(async () => { + try { + const root = await navigator.storage.getDirectory(); + await root.getDirectoryHandle('stelekit', { create: false }); + return true; + } catch { + return false; + } + }); + expect(hasOpfsData, 'OPFS stelekit directory must exist after app init').toBe(true); + + expect(errors, `Uncaught JS errors: ${errors.join(' | ')}`).toHaveLength(0); +}); diff --git a/kmp/build.gradle.kts b/kmp/build.gradle.kts index 616e9e20..01452693 100644 --- a/kmp/build.gradle.kts +++ b/kmp/build.gradle.kts @@ -151,7 +151,7 @@ kotlin { if (project.findProperty("enableJs") == "true") { val wasmJsMain by getting { dependencies { - // Phase B: add @sqlite.org/sqlite-wasm driver here + implementation(npm("@sqlite.org/sqlite-wasm", "3.46.1")) } } @@ -807,6 +807,7 @@ sqldelight { databases { create("SteleDatabase") { packageName.set("dev.stapler.stelekit.db") + generateAsync.set(true) } } } diff --git a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/db/DriverFactory.android.kt b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/db/DriverFactory.android.kt index e95ab3c1..4a258209 100644 --- a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/db/DriverFactory.android.kt +++ b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/db/DriverFactory.android.kt @@ -4,6 +4,7 @@ import android.content.Context import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory +import kotlinx.coroutines.runBlocking actual class DriverFactory actual constructor() { @@ -57,7 +58,7 @@ actual class DriverFactory actual constructor() { try { driver.execute(null, "PRAGMA cache_size=-8000;", 0) } catch (_: Exception) { } // Apply incremental DDL migrations (idempotent, hash-tracked). - MigrationRunner.applyAll(driver) + runBlocking { MigrationRunner.applyAll(driver) } return driver } diff --git a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/db/DriverFactory.android.kt.tmp.4573.1775983611619 b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/db/DriverFactory.android.kt.tmp.4573.1775983611619 deleted file mode 100644 index 9fb8d5b7..00000000 --- a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/db/DriverFactory.android.kt.tmp.4573.1775983611619 +++ /dev/null @@ -1,68 +0,0 @@ -package dev.stapler.stelekit.db - -import android.content.Context -import app.cash.sqldelight.db.SqlDriver -import app.cash.sqldelight.driver.android.AndroidSqliteDriver -import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory - -actual class DriverFactory actual constructor() { - companion object { - internal var staticContext: Context? = null - - fun setContext(context: Context) { - staticContext = context.applicationContext - } - } - - actual fun init(context: Any) { - if (context is Context) { - setContext(context) - } - } - - actual fun createDriver(jdbcUrl: String): SqlDriver { - val dbName = jdbcUrl.substringAfter("jdbc:sqlite:") - - val context = staticContext ?: throw IllegalStateException("DriverFactory must be initialized with a Context before creating a driver. Call DriverFactory().init(context) first.") - - // Ensure parent directory exists for absolute paths - if (dbName.startsWith("/")) { - java.io.File(dbName).parentFile?.mkdirs() - } - - val driver = AndroidSqliteDriver( - schema = SteleDatabase.Schema, - context = context, - name = dbName, - factory = RequerySQLiteOpenHelperFactory() - ) - - // Incremental migrations - try { - driver.execute(null, "ALTER TABLE blocks ADD COLUMN content_hash TEXT;", 0) - } catch (_: Exception) { } - try { - driver.execute(null, "CREATE INDEX IF NOT EXISTS idx_blocks_content_hash ON blocks(content_hash);", 0) - } catch (_: Exception) { } - try { - driver.execute(null, "ALTER TABLE pages ADD COLUMN is_content_loaded INTEGER NOT NULL DEFAULT 1;", 0) - } catch (_: Exception) { } - - return driver - } - - actual fun getDatabaseUrl(graphId: String): String { - val basePath = getDatabaseDirectory() - return "jdbc:sqlite:$basePath/logseq-graph-$graphId.db" - } - - actual fun getDatabaseDirectory(): String { - val context = staticContext ?: throw IllegalStateException("DriverFactory not initialized with a Context.") - return context.filesDir.absolutePath - } -} - -actual val defaultDatabaseUrl: String - get() { - return "jdbc:sqlite:logseq.db" - } 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 252aa968..b203a7a8 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/MigrationRunner.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/MigrationRunner.kt @@ -186,7 +186,7 @@ object MigrationRunner { * (which would leave the schema in an indeterminate state). The correct fix is always * to add a new migration entry rather than edit an existing one. */ - fun applyAll(driver: SqlDriver) { + suspend fun applyAll(driver: SqlDriver) { // Bootstrap the tracking table — must succeed before anything else. driver.execute( identifier = null, @@ -198,23 +198,25 @@ object MigrationRunner { ) """.trimIndent(), parameters = 0 - ) + ).await() // Load both name and hash so we can detect tampering. val appliedByName: Map = driver.executeQuery( identifier = null, sql = "SELECT name, hash FROM schema_migrations", mapper = { cursor -> - val map = mutableMapOf() - while (cursor.next().value) { - val name = cursor.getString(0) - val hash = cursor.getString(1) - if (name != null && hash != null) map[name] = hash + QueryResult.AsyncValue { + val map = mutableMapOf() + while (cursor.next().await()) { + val name = cursor.getString(0) + val hash = cursor.getString(1) + if (name != null && hash != null) map[name] = hash + } + map as Map } - QueryResult.Value(map as Map) }, parameters = 0 - ).value + ).await() for (migration in all) { val recordedHash = appliedByName[migration.name] @@ -231,7 +233,7 @@ object MigrationRunner { for (sql in migration.statements) { try { - driver.execute(null, sql.trimIndent(), 0) + driver.execute(null, sql.trimIndent(), 0).await() } catch (e: CancellationException) { throw e } catch (_: Exception) { @@ -248,7 +250,7 @@ object MigrationRunner { ) { bindString(0, migration.hash) bindString(1, migration.name) - } + }.await() } } diff --git a/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/db/DriverFactory.jvm.kt b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/db/DriverFactory.jvm.kt index cb234b30..1b969e11 100644 --- a/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/db/DriverFactory.jvm.kt +++ b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/db/DriverFactory.jvm.kt @@ -2,6 +2,7 @@ package dev.stapler.stelekit.db import app.cash.sqldelight.db.SqlDriver import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking import java.io.File import java.io.IOException import java.util.Properties @@ -57,7 +58,7 @@ actual class DriverFactory actual constructor() { val driver = PooledJdbcSqliteDriver(jdbcUrl, connectionProps, poolSize = poolSize) try { - SteleDatabase.Schema.create(driver) + runBlocking { SteleDatabase.Schema.create(driver).await() } } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -66,7 +67,7 @@ actual class DriverFactory actual constructor() { log.warning("Schema creation: ${e.message}") } - MigrationRunner.applyAll(driver) + runBlocking { MigrationRunner.applyAll(driver) } return driver } diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/browser/Main.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/browser/Main.kt index 5b6c42e1..9abaf9ff 100644 --- a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/browser/Main.kt +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/browser/Main.kt @@ -4,32 +4,51 @@ package dev.stapler.stelekit.browser -import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.window.CanvasBasedWindow import dev.stapler.stelekit.db.DriverFactory import dev.stapler.stelekit.db.GraphManager -import dev.stapler.stelekit.platform.DemoFileSystem +import dev.stapler.stelekit.platform.PlatformFileSystem import dev.stapler.stelekit.platform.PlatformSettings import dev.stapler.stelekit.repository.GraphBackend import dev.stapler.stelekit.ui.StelekitApp +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch @OptIn(ExperimentalComposeUiApi::class) fun main() { - CanvasBasedWindow(canvasElementId = "ComposeTarget") { - val demoFileSystem = remember { DemoFileSystem() } - val graphManager = remember { - GraphManager( - platformSettings = PlatformSettings(), - driverFactory = DriverFactory(), - fileSystem = demoFileSystem, - defaultBackend = GraphBackend.IN_MEMORY, - ) + val scope = MainScope() + scope.launch { + val graphId = "default" + val graphPath = "/stelekit/$graphId" + + val fileSystem = PlatformFileSystem() + fileSystem.preload(graphPath) + + val driverFactory = DriverFactory() + val backend = try { + driverFactory.createDriverAsync(graphId) + GraphBackend.SQLDELIGHT + } catch (e: Throwable) { + println("[SteleKit] OPFS driver init failed, using IN_MEMORY: ${e.message}") + GraphBackend.IN_MEMORY } - StelekitApp( - fileSystem = demoFileSystem, - graphPath = "/demo", - graphManager = graphManager, + + val graphManager = GraphManager( + platformSettings = PlatformSettings(), + driverFactory = driverFactory, + fileSystem = fileSystem, + defaultBackend = backend, ) + + js("window.__stelekit_ready = true") + + CanvasBasedWindow(canvasElementId = "ComposeTarget") { + StelekitApp( + fileSystem = fileSystem, + graphPath = graphPath, + graphManager = graphManager, + ) + } } } diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/DriverFactory.js.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/DriverFactory.js.kt index 1636b6e2..a4931a47 100644 --- a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/DriverFactory.js.kt +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/DriverFactory.js.kt @@ -1,15 +1,28 @@ package dev.stapler.stelekit.db import app.cash.sqldelight.db.SqlDriver +import kotlinx.coroutines.await actual class DriverFactory actual constructor() { + private var cachedDriver: WasmOpfsSqlDriver? = null + actual fun init(context: Any) {} - actual fun createDriver(jdbcUrl: String): SqlDriver { - // Phase B: replace with @sqlite.org/sqlite-wasm driver - throw UnsupportedOperationException("Use RepositoryBackend.IN_MEMORY for browser demo") - } + + actual fun createDriver(jdbcUrl: String): SqlDriver = + cachedDriver ?: error("createDriverAsync() must be called before createDriver() on wasmJs") + actual fun getDatabaseUrl(graphId: String): String = "jdbc:sqlite:stelekit-graph-$graphId" actual fun getDatabaseDirectory(): String = "/stelekit" + + suspend fun createDriverAsync(graphId: String): WasmOpfsSqlDriver { + val opfsPath = "/graph-${graphId}.sqlite3" + val driver = WasmOpfsSqlDriver(workerScriptPath = "./sqlite-stelekit-worker.js") + driver.init(opfsPath) + SteleDatabase.Schema.create(driver).await() + MigrationRunner.applyAll(driver) + cachedDriver = driver + return driver + } } actual val defaultDatabaseUrl: String diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/JsBindCollector.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/JsBindCollector.kt new file mode 100644 index 00000000..79af5344 --- /dev/null +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/JsBindCollector.kt @@ -0,0 +1,25 @@ +package dev.stapler.stelekit.db + +import app.cash.sqldelight.db.SqlPreparedStatement + +internal class JsBindCollector : SqlPreparedStatement { + private val arr: JsAny = emptyJsArray() + + override fun bindBoolean(index: Int, boolean: Boolean?) { + if (boolean == null) jsArrayPushNull(arr) else jsArrayPushLong(arr, if (boolean) 1L else 0L) + } + override fun bindBytes(index: Int, bytes: ByteArray?) { + if (bytes == null) jsArrayPushNull(arr) else jsArrayPushString(arr, bytes.decodeToString()) + } + override fun bindDouble(index: Int, double: Double?) { + if (double == null) jsArrayPushNull(arr) else jsArrayPushDouble(arr, double) + } + override fun bindLong(index: Int, long: Long?) { + if (long == null) jsArrayPushNull(arr) else jsArrayPushLong(arr, long) + } + override fun bindString(index: Int, string: String?) { + if (string == null) jsArrayPushNull(arr) else jsArrayPushString(arr, string) + } + + fun toJsArray(): JsAny = arr +} diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/JsRowCursor.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/JsRowCursor.kt new file mode 100644 index 00000000..9a538aae --- /dev/null +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/JsRowCursor.kt @@ -0,0 +1,58 @@ +package dev.stapler.stelekit.db + +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlCursor + +internal class JsRowCursor(private val rows: JsAny) : SqlCursor { + private var index = -1 + private var columnNames: List? = null + private val count = rowsLength(rows) + + override fun next(): QueryResult { + index++ + if (index < count && columnNames == null) { + val row = getRow(rows, 0) + val keys = getColumnNames(row) + val len = jsArrayLength(keys) + columnNames = (0 until len).map { jsArrayGetString(keys, it) } + } + return QueryResult.Value(index < count) + } + + private fun currentValue(columnIndex: Int): JsAny? { + val cols = columnNames ?: return null + if (columnIndex >= cols.size) return null + val row = getRow(rows, index) + return getColumnValue(row, cols[columnIndex]) + } + + override fun getString(index: Int): String? { + val v = currentValue(index) ?: return null + if (jsValueIsNull(v)) return null + return jsValueToString(v) + } + + override fun getLong(index: Int): Long? { + val v = currentValue(index) ?: return null + if (jsValueIsNull(v)) return null + return jsValueToDouble(v).toLong() + } + + override fun getDouble(index: Int): Double? { + val v = currentValue(index) ?: return null + if (jsValueIsNull(v)) return null + return jsValueToDouble(v) + } + + override fun getBytes(index: Int): ByteArray? { + val v = currentValue(index) ?: return null + if (jsValueIsNull(v)) return null + return jsValueToString(v).encodeToByteArray() + } + + override fun getBoolean(index: Int): Boolean? { + val v = currentValue(index) ?: return null + if (jsValueIsNull(v)) return null + return jsValueToDouble(v).toLong() != 0L + } +} diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/SqliteWorkerInterop.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/SqliteWorkerInterop.kt new file mode 100644 index 00000000..dbee8954 --- /dev/null +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/SqliteWorkerInterop.kt @@ -0,0 +1,80 @@ +package dev.stapler.stelekit.db + +internal fun createSqliteWorker(scriptPath: String): JsAny = + js("new Worker(scriptPath, { type: 'module' })") + +internal fun workerPostMessage(worker: JsAny, message: JsAny): Unit = + js("worker.postMessage(message)") + +internal fun buildInitMessage(dbPath: String): JsAny = + js("({ type: 'init', dbPath: dbPath })") + +internal fun buildExecMessage(id: Int, sql: String, bindArray: JsAny): JsAny = + js("({ type: 'exec', id: id, sql: sql, bind: bindArray })") + +internal fun buildQueryMessage(id: Int, sql: String, bindArray: JsAny): JsAny = + js("({ type: 'query', id: id, sql: sql, bind: bindArray })") + +internal fun buildTransactionBeginMessage(id: Int): JsAny = + js("({ type: 'transaction-begin', id: id })") + +internal fun buildTransactionEndMessage(id: Int, successful: Boolean): JsAny = + js("({ type: 'transaction-end', id: id, successful: successful })") + +internal fun buildExecuteLongMessage(id: Int, sql: String, bindArray: JsAny): JsAny = + js("({ type: 'execute-long', id: id, sql: sql, bind: bindArray })") + +internal fun emptyJsArray(): JsAny = js("[]") + +internal fun jsArrayPushString(arr: JsAny, value: String): Unit = js("arr.push(value)") +internal fun jsArrayPushLong(arr: JsAny, value: Long): Unit = js("arr.push(Number(value))") +internal fun jsArrayPushDouble(arr: JsAny, value: Double): Unit = js("arr.push(value)") +internal fun jsArrayPushNull(arr: JsAny): Unit = js("arr.push(null)") + +internal fun getMessageType(msg: JsAny): String = js("msg.type") +internal fun getMessageId(msg: JsAny): Int = js("msg.id | 0") +internal fun getMessageRows(msg: JsAny): JsAny = js("msg.rows") +internal fun getMessageChanges(msg: JsAny): Long = js("BigInt(msg.value)") +internal fun getMessageError(msg: JsAny): String = js("msg.message") +internal fun getMessageBackend(msg: JsAny): String = js("msg.backend") +internal fun getMessageWarning(msg: JsAny): String? = js("msg.warning || null") +internal fun rowsLength(rows: JsAny): Int = js("rows.length | 0") +internal fun getRow(rows: JsAny, index: Int): JsAny = js("rows[index]") +internal fun getColumnNames(row: JsAny): JsAny = js("Object.keys(row)") +internal fun jsArrayLength(arr: JsAny): Int = js("arr.length | 0") +internal fun jsArrayGetString(arr: JsAny, index: Int): String = js("arr[index]") +internal fun getColumnValue(row: JsAny, name: String): JsAny? = js("row[name] ?? null") +internal fun jsValueToString(v: JsAny): String = js("String(v)") +internal fun jsValueToDouble(v: JsAny): Double = js("Number(v)") +internal fun jsValueIsNull(v: JsAny?): Boolean = js("v === null || v === undefined") +internal fun jsValueIsNumber(v: JsAny): Boolean = js("typeof v === 'number'") +internal fun jsValueIsString(v: JsAny): Boolean = js("typeof v === 'string'") + +internal fun createWorkerReadyPromise(worker: JsAny): kotlin.js.Promise = js(""" + new Promise(function(resolve) { + function onReady(e) { + if (e.data.type === 'ready') { + worker.removeEventListener('message', onReady); + resolve(e.data); + } + } + worker.addEventListener('message', onReady); + }) +""") + +internal fun createWorkerResponsePromise(worker: JsAny, id: Int): kotlin.js.Promise = js(""" + new Promise(function(resolve, reject) { + function handler(e) { + var data = e.data; + if ((data.type === 'result' || data.type === 'long-result' || data.type === 'error') && (data.id | 0) === (id | 0)) { + worker.removeEventListener('message', handler); + if (data.type === 'error') { + reject(new Error(data.message)); + } else { + resolve(data); + } + } + } + worker.addEventListener('message', handler); + }) +""") diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/WasmOpfsSqlDriver.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/WasmOpfsSqlDriver.kt new file mode 100644 index 00000000..fdade4e5 --- /dev/null +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/WasmOpfsSqlDriver.kt @@ -0,0 +1,106 @@ +package dev.stapler.stelekit.db + +import app.cash.sqldelight.Query +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlCursor +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.SqlPreparedStatement +import kotlinx.coroutines.await + +class WasmOpfsSqlDriver(private val workerScriptPath: String) : SqlDriver { + + private val worker: JsAny = createSqliteWorker(workerScriptPath) + private var nextId = 0 + private val listeners = mutableMapOf>() + var actualBackend: String = "unknown" + private set + + suspend fun init(dbPath: String) { + val readyPromise = createWorkerReadyPromise(worker) + workerPostMessage(worker, buildInitMessage(dbPath)) + val readyMsg = readyPromise.await() + actualBackend = getMessageBackend(readyMsg) + val warning = getMessageWarning(readyMsg) + if (warning != null) { + println("[SteleKit] SQLite worker fallback: $warning") + } + } + + private fun nextMsgId(): Int = nextId++ + + override fun execute( + identifier: Int?, + sql: String, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)? + ): QueryResult = QueryResult.AsyncValue { + val id = nextMsgId() + val bindArr = if (binders != null) { + val c = JsBindCollector() + binders(c) + c.toJsArray() + } else emptyJsArray() + val promise = createWorkerResponsePromise(worker, id) + workerPostMessage(worker, buildExecuteLongMessage(id, sql, bindArr)) + val resp = promise.await() + getMessageChanges(resp) + } + + override fun executeQuery( + identifier: Int?, + sql: String, + mapper: (SqlCursor) -> QueryResult, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)? + ): QueryResult = QueryResult.AsyncValue { + val id = nextMsgId() + val bindArr = if (binders != null) { + val c = JsBindCollector() + binders(c) + c.toJsArray() + } else emptyJsArray() + val promise = createWorkerResponsePromise(worker, id) + workerPostMessage(worker, buildQueryMessage(id, sql, bindArr)) + val resp = promise.await() + val rows = getMessageRows(resp) + val cursor = JsRowCursor(rows) + mapper(cursor).await() + } + + override fun newTransaction(): QueryResult = QueryResult.AsyncValue { + val id = nextMsgId() + val promise = createWorkerResponsePromise(worker, id) + workerPostMessage(worker, buildTransactionBeginMessage(id)) + promise.await() + object : Transacter.Transaction() { + override val enclosingTransaction: Transacter.Transaction? = null + override fun endTransaction(successful: Boolean): QueryResult = + this@WasmOpfsSqlDriver.endTransaction(successful) + } + } + + override fun endTransaction(successful: Boolean): QueryResult = QueryResult.AsyncValue { + val id = nextMsgId() + val promise = createWorkerResponsePromise(worker, id) + workerPostMessage(worker, buildTransactionEndMessage(id, successful)) + promise.await() + Unit + } + + override fun currentTransaction(): Transacter.Transaction? = null + + override fun addListener(vararg queryKeys: String, listener: Query.Listener) { + queryKeys.forEach { key -> listeners.getOrPut(key) { mutableSetOf() }.add(listener) } + } + + override fun removeListener(vararg queryKeys: String, listener: Query.Listener) { + queryKeys.forEach { key -> listeners[key]?.remove(listener) } + } + + override fun notifyListeners(vararg queryKeys: String) { + queryKeys.forEach { key -> listeners[key]?.toSet()?.forEach { it.queryResultsChanged() } } + } + + override fun close() {} +} diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/OpfsInterop.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/OpfsInterop.kt new file mode 100644 index 00000000..458bd2f0 --- /dev/null +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/OpfsInterop.kt @@ -0,0 +1,79 @@ +package dev.stapler.stelekit.platform + +import kotlinx.coroutines.await + +internal suspend fun getOpfsRoot(): JsAny = + (js("navigator.storage.getDirectory()") as kotlin.js.Promise).await() + +internal suspend fun getDirectoryHandle(parent: JsAny, name: String, create: Boolean): JsAny = + (js("parent.getDirectoryHandle(name, { create: create })") as kotlin.js.Promise).await() + +internal suspend fun getFileHandle(parent: JsAny, name: String, create: Boolean): JsAny = + (js("parent.getFileHandle(name, { create: create })") as kotlin.js.Promise).await() + +private fun iteratorValues(handle: JsAny): JsAny = js("handle.values()") +private fun iteratorNext(iter: JsAny): kotlin.js.Promise = js("iter.next()") +private fun iterResultDone(result: JsAny): Boolean = js("result.done === true") +private fun iterResultValue(result: JsAny): JsAny = js("result.value") + +internal suspend fun listOpfsEntries(dirHandle: JsAny): List { + val entries = mutableListOf() + val iterator = iteratorValues(dirHandle) + while (true) { + val next = iteratorNext(iterator).await() + if (iterResultDone(next)) break + entries.add(iterResultValue(next)) + } + return entries +} + +internal fun getEntryName(entry: JsAny): String = js("entry.name") +internal fun isFileEntry(entry: JsAny): Boolean = js("entry.kind === 'file'") +internal fun isDirectoryEntry(entry: JsAny): Boolean = js("entry.kind === 'directory'") + +private fun fileHandleGetFile(handle: JsAny): kotlin.js.Promise = js("handle.getFile()") +private fun fileText(file: JsAny): kotlin.js.Promise = js("file.text()") +private fun jsStringValue(v: JsAny): String = js("String(v)") + +internal suspend fun readOpfsFile(fileHandle: JsAny): String? = try { + val file = fileHandleGetFile(fileHandle).await() + jsStringValue(fileText(file).await()) +} catch (e: Throwable) { + null +} + +private fun fileHandleCreateWritable(handle: JsAny): kotlin.js.Promise = js("handle.createWritable()") +private fun writableWrite(writable: JsAny, content: String): kotlin.js.Promise = js("writable.write(content)") +private fun writableClose(writable: JsAny): kotlin.js.Promise = js("writable.close()") +private fun dirRemoveEntry(dir: JsAny, name: String): kotlin.js.Promise = js("dir.removeEntry(name)") + +internal suspend fun opfsWriteFile(path: String, content: String) { + try { + val root = getOpfsRoot() + val parts = path.removePrefix("/").split("/") + var dir: JsAny = root + for (part in parts.dropLast(1)) { + dir = getDirectoryHandle(dir, part, true) + } + val fileName = parts.last() + val fileHandle = getFileHandle(dir, fileName, true) + val writable = fileHandleCreateWritable(fileHandle).await() + writableWrite(writable, content).await() + writableClose(writable).await() + } catch (e: Throwable) { + println("[SteleKit] OPFS write failed for $path: ${e.message}") + } +} + +internal suspend fun opfsDeleteFile(path: String) { + try { + val root = getOpfsRoot() + val parts = path.removePrefix("/").split("/") + var dir: JsAny = root + for (part in parts.dropLast(1)) { + dir = getDirectoryHandle(dir, part, false) + } + val fileName = parts.last() + dirRemoveEntry(dir, fileName).await() + } catch (_: Throwable) {} +} diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/PlatformFileSystem.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/PlatformFileSystem.kt index 6d31eb6c..0f6f0b89 100644 --- a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/PlatformFileSystem.kt +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/PlatformFileSystem.kt @@ -1,21 +1,80 @@ package dev.stapler.stelekit.platform +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + actual class PlatformFileSystem actual constructor() : FileSystem { - private val homeDir: String = "/stelekit" + private val homeDir = "/stelekit" + private val cache = mutableMapOf() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - actual override fun getDefaultGraphPath(): String = homeDir + suspend fun preload(graphPath: String) { + try { + loadOpfsDirectory(graphPath) + } catch (e: Throwable) { + println("[SteleKit] OPFS preload failed, starting with empty graph: ${e.message}") + } + } + + private suspend fun loadOpfsDirectory(graphPath: String) { + val root = getOpfsRoot() + val parts = graphPath.removePrefix("/").split("/") + var dir: JsAny = root + for (part in parts) { + dir = try { + getDirectoryHandle(dir, part, false) + } catch (e: Throwable) { + return + } + } + loadFilesRecursive(dir, graphPath) + } + + private suspend fun loadFilesRecursive(dirHandle: JsAny, currentPath: String) { + val entries = listOpfsEntries(dirHandle) + for (entry in entries) { + val name = getEntryName(entry) + val path = "$currentPath/$name" + if (isFileEntry(entry)) { + val content = readOpfsFile(entry) + if (content != null) cache[path] = content + } else if (isDirectoryEntry(entry)) { + loadFilesRecursive(entry, path) + } + } + } + actual override fun getDefaultGraphPath(): String = homeDir actual override fun expandTilde(path: String): String = if (path.startsWith("~")) path.replaceFirst("~", homeDir) else path - actual override fun readFile(path: String): String? = null - actual override fun writeFile(path: String, content: String): Boolean = true - actual override fun listFiles(path: String): List = emptyList() - actual override fun listDirectories(path: String): List = emptyList() - actual override fun fileExists(path: String): Boolean = false - actual override fun directoryExists(path: String): Boolean = false + actual override fun readFile(path: String): String? = cache[path] + actual override fun fileExists(path: String): Boolean = cache.containsKey(path) + actual override fun listFiles(path: String): List = + cache.keys.filter { it.startsWith("$path/") && !it.removePrefix("$path/").contains('/') } + actual override fun listDirectories(path: String): List = + cache.keys + .filter { it.startsWith("$path/") } + .map { it.removePrefix("$path/").substringBefore('/') } + .filter { it.isNotEmpty() && cache.keys.any { k -> k.startsWith("$path/$it/") } } + .distinct() + .map { "$path/$it" } + + actual override fun writeFile(path: String, content: String): Boolean { + cache[path] = content + scope.launch { opfsWriteFile(path, content) } + return true + } + + actual override fun directoryExists(path: String): Boolean = true actual override fun createDirectory(path: String): Boolean = true - actual override fun deleteFile(path: String): Boolean = true + actual override fun deleteFile(path: String): Boolean { + cache.remove(path) + scope.launch { opfsDeleteFile(path) } + return true + } actual override fun pickDirectory(): String? = null actual override suspend fun pickDirectoryAsync(): String? = null actual override fun getLastModifiedTime(path: String): Long? = null diff --git a/kmp/src/wasmJsMain/resources/sqlite-stelekit-worker.js b/kmp/src/wasmJsMain/resources/sqlite-stelekit-worker.js new file mode 100644 index 00000000..9f17fed7 --- /dev/null +++ b/kmp/src/wasmJsMain/resources/sqlite-stelekit-worker.js @@ -0,0 +1,47 @@ +import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; + +let db = null; + +async function init(dbPath) { + const sqlite3 = await sqlite3InitModule({ print: console.log, printErr: console.error }); + try { + const poolUtil = await sqlite3.installOpfsSAHPoolVfs({ + name: 'opfs-sahpool', + directory: '/stelekit', + initialCapacity: 6, + clearOnInit: false, + }); + db = new poolUtil.OpfsSAHPoolDb(dbPath); + self.postMessage({ type: 'ready', backend: 'opfs-sahpool' }); + } catch (e) { + console.warn('[SteleKit] OPFS unavailable, falling back to in-memory:', e.message); + db = new sqlite3.oo1.DB(':memory:'); + self.postMessage({ type: 'ready', backend: 'memory', warning: e.message }); + } +} + +self.onmessage = async (e) => { + const { type, id, sql, bind, dbPath, successful } = e.data; + try { + if (type === 'init') { + await init(dbPath); + return; + } + if (type === 'exec' || type === 'query') { + const rows = []; + db.exec({ sql, bind: bind ?? [], rowMode: 'object', callback: r => rows.push({ ...r }) }); + self.postMessage({ type: 'result', id, rows }); + } else if (type === 'execute-long') { + db.exec({ sql, bind: bind ?? [] }); + self.postMessage({ type: 'long-result', id, value: db.changes() }); + } else if (type === 'transaction-begin') { + db.exec('BEGIN'); + self.postMessage({ type: 'result', id, rows: [] }); + } else if (type === 'transaction-end') { + db.exec(successful ? 'COMMIT' : 'ROLLBACK'); + self.postMessage({ type: 'result', id, rows: [] }); + } + } catch (err) { + self.postMessage({ type: 'error', id, message: err.message }); + } +}; diff --git a/scripts/serve-web.sh b/scripts/serve-web.sh new file mode 100755 index 00000000..5793010d --- /dev/null +++ b/scripts/serve-web.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +echo "Building wasmJs distribution..." +./gradlew :kmp:wasmJsBrowserDistribution -PenableJs=true + +DIST="$ROOT/kmp/build/dist/wasmJs/productionExecutable" +echo "" +echo "Starting local server at http://localhost:8787" +echo "Serving: $DIST" +DEMO_DIST="$DIST" node e2e/server.mjs From d520fd138efdfbd47a96f2cc1cf557adfd4c7fab Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Thu, 7 May 2026 06:01:50 -0700 Subject: [PATCH 2/8] docs(web): add OPFS storage project plans and research Planning artifacts for the OPFS SQLite browser storage feature: requirements, architecture research (stack, features, pitfalls), implementation plan, and test validation plan. Co-Authored-By: Claude Sonnet 4.6 --- .../stelekit-web-opfs/implementation/plan.md | 761 ++++++++++++++++++ .../implementation/validation.md | 284 +++++++ .../stelekit-web-opfs/requirements.md | 73 ++ .../stelekit-web-opfs/research/01-stack.md | 164 ++++ .../stelekit-web-opfs/research/02-features.md | 315 ++++++++ .../research/03-architecture.md | 273 +++++++ .../stelekit-web-opfs/research/04-pitfalls.md | 251 ++++++ 7 files changed, 2121 insertions(+) create mode 100644 project_plans/stelekit-web-opfs/implementation/plan.md create mode 100644 project_plans/stelekit-web-opfs/implementation/validation.md create mode 100644 project_plans/stelekit-web-opfs/requirements.md create mode 100644 project_plans/stelekit-web-opfs/research/01-stack.md create mode 100644 project_plans/stelekit-web-opfs/research/02-features.md create mode 100644 project_plans/stelekit-web-opfs/research/03-architecture.md create mode 100644 project_plans/stelekit-web-opfs/research/04-pitfalls.md diff --git a/project_plans/stelekit-web-opfs/implementation/plan.md b/project_plans/stelekit-web-opfs/implementation/plan.md new file mode 100644 index 00000000..64c7b646 --- /dev/null +++ b/project_plans/stelekit-web-opfs/implementation/plan.md @@ -0,0 +1,761 @@ +# Implementation Plan: SteleKit Web — OPFS Durable Storage & Local Dev + +**Project**: stelekit-web-opfs +**Phase**: 3 — Architecture + Task Breakdown +**Date**: 2026-05-07 + +--- + +## Summary + +3 epics, 3 stories, 13 tasks. + +Replace the browser's hard-coded `IN_MEMORY` + `DemoFileSystem` stack with a real OPFS-backed SQLite driver and file system. The only practical path is: + +1. `@sqlite.org/sqlite-wasm` running inside a **JS Web Worker** (OPFS sync access is browser-main-thread-blocked). +2. Kotlin/WASM talking to that worker via postMessage wrapped as JS `Promise`s. +3. A custom `SqlDriver` returning `QueryResult.AsyncValue`, gated by `generateAsync = true` in the SQLDelight Gradle config. + +No `app.cash.sqldelight:web-worker-driver-wasm-js` artifact exists at 2.3.2; a custom driver is mandatory. + +--- + +## Risk Register + +| ID | Risk | Likelihood | Impact | Mitigation | +|----|------|-----------|--------|------------| +| R1 | `generateAsync = true` regenerates ALL query code to suspend functions — may break JVM/Android tests | Medium | High | JVM `sqlite-driver` and Android `android-driver` both support synchronous `QueryResult.Value`, which is compatible with async callers in coroutine context. Run full `ciCheck` immediately after enabling. If tests break, use `expect/actual` shim to keep JVM/Android sync. | +| R2 | Worker module path wrong in built dist (webpack outputs file at different path than `new Worker('./sqlite-stelekit-worker.js')` resolves) | High | High | Must verify the worker script path after first successful webpack build. Check `kmp/build/dist/wasmJs/productionExecutable/` for actual output filenames. | +| R3 | CDN delivery of `sqlite3.wasm` blocked by COEP `require-corp` if CDN does not set CORP header | Medium | High | Bundle `@sqlite.org/sqlite-wasm` locally via npm; prefer bundled over CDN for all CI/production use. | +| R4 | `opfs-sahpool` exclusive-lock prevents multi-tab use | Low | Low | Document single-tab assumption. Acceptable per requirements. | +| R5 | Private browsing (Firefox/Safari) disables OPFS entirely — driver init throws | Low | Medium | Handled by fallback in worker init: catch error, post `{ backend: 'memory' }`, Kotlin keeps `IN_MEMORY`. | + +--- + +## Epic 1: Local Dev Script + +**Goal**: Single command builds wasmJs and starts the local preview server. + +### Story 1.1 — `serve-web.sh` + +**Acceptance**: `./scripts/serve-web.sh` builds the WASM distribution and starts `node e2e/server.mjs`. + +#### Task 1.1.1 — Create `scripts/serve-web.sh` + +**File**: `scripts/serve-web.sh` (new) +**Dependencies**: none + +Steps: +1. `chmod +x` the script (set in the file header). +2. Build the wasmJs distribution: + ```bash + ./gradlew :kmp:wasmJsBrowserDistribution -PenableJs=true + ``` +3. Start the server pointing at the output directory: + ```bash + node e2e/server.mjs + ``` +4. Print the local URL (`http://localhost:8787`) to stdout. + +Script skeleton: +```bash +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +echo "Building wasmJs distribution..." +./gradlew :kmp:wasmJsBrowserDistribution -PenableJs=true + +echo "" +echo "Starting local server at http://localhost:8787" +node e2e/server.mjs +``` + +**Notes**: No port configuration needed — `server.mjs` already defaults to 8787. Must work on macOS and Linux (no GNU-specific flags). Script lives alongside existing `scripts/benchmark-local.sh`. + +--- + +## Epic 2: OPFS SQLite Driver + +**Goal**: `DriverFactory.js.kt` returns a working `SqlDriver` backed by `@sqlite.org/sqlite-wasm` using the `opfs-sahpool` VFS. Fallback to `IN_MEMORY` if OPFS is unavailable. + +### Story 2.1 — JS Layer (Worker + npm dependency) + +Tasks 2.1.1 and 2.1.2 are **parallel** (no dependency between them). + +#### Task 2.1.1 — Add `@sqlite.org/sqlite-wasm` npm dependency + +**File**: `kmp/build.gradle.kts` +**Dependencies**: none + +In the `wasmJsMain` source-set dependencies block (currently has a `// Phase B` comment at line ~154): + +```kotlin +val wasmJsMain by getting { + dependencies { + implementation(npm("@sqlite.org/sqlite-wasm", "3.46.1")) + } +} +``` + +**Notes**: Use `npm()` Gradle function, not the `"npm:..."` string syntax. Version 3.46.1 is the version used in research; pin explicitly. The package ships `sqlite3.wasm`; webpack 5 must copy it as a static asset. Verify the `sqlite3.wasm` is present in `kmp/build/dist/wasmJs/productionExecutable/` after first build. + +#### Task 2.1.2 — Create `sqlite-stelekit-worker.js` + +**File**: `kmp/src/wasmJsMain/resources/sqlite-stelekit-worker.js` (new) +**Dependencies**: none (parallel with 2.1.1) + +This is a pure JS Web Worker file. It must: + +1. Import `sqlite3InitModule` from `@sqlite.org/sqlite-wasm` (ES module import — worker must be launched with `{ type: 'module' }`). +2. On `type: 'init'` message: call `sqlite3InitModule`, then `installOpfsSAHPoolVfs` with `clearOnInit: false`. Open `OpfsSAHPoolDb` at the given `dbPath`. Post `{ type: 'ready', backend: 'opfs-sahpool' }` on success, or fall back to `:memory:` and post `{ type: 'ready', backend: 'memory', warning: '...' }` on failure. +3. On `type: 'exec'` message: run `db.exec({ sql, bind, rowMode: 'object', ... })`, collect rows, post `{ type: 'result', id, rows }`. +4. On `type: 'query'` message: same as exec but used for SELECT paths. +5. On `type: 'transaction-begin'` / `type: 'transaction-end'` / `type: 'transaction-rollback'`: delegate to `db.transaction()` or manual BEGIN/COMMIT/ROLLBACK. +6. On `type: 'execute-long'` message (for DDL/migrations returning row count): run exec, post `{ type: 'long-result', id, value }`. + +Message protocol (Kotlin ↔ Worker): + +``` +Kotlin → Worker: + { type: 'init', dbPath: '/stelekit/graph-.sqlite3' } + { type: 'exec', id: number, sql: string, bind: any[] } + { type: 'query', id: number, sql: string, bind: any[] } + { type: 'transaction-begin', id: number } + { type: 'transaction-end', id: number, successful: boolean } + { type: 'execute-long', id: number, sql: string, bind: any[] } + +Worker → Kotlin: + { type: 'ready', backend: 'opfs-sahpool' | 'memory', warning?: string } + { type: 'result', id: number, rows: object[] } + { type: 'long-result', id: number, value: number } + { type: 'error', id: number, message: string } +``` + +Worker initialization code (reference): +```js +import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; + +let db = null; +const pending = new Map(); // id → { resolve, reject } + +async function init(dbPath) { + const sqlite3 = await sqlite3InitModule({ print: console.log, printErr: console.error }); + try { + const poolUtil = await sqlite3.installOpfsSAHPoolVfs({ + name: 'opfs-sahpool', + directory: '/stelekit', + initialCapacity: 6, + clearOnInit: false, + }); + db = new poolUtil.OpfsSAHPoolDb(dbPath); + self.postMessage({ type: 'ready', backend: 'opfs-sahpool' }); + } catch (e) { + console.warn('[SteleKit] OPFS unavailable, falling back to in-memory:', e.message); + db = new sqlite3.oo1.DB(':memory:'); + self.postMessage({ type: 'ready', backend: 'memory', warning: e.message }); + } +} + +self.onmessage = async (e) => { + const { type, id, sql, bind, dbPath, successful } = e.data; + try { + if (type === 'init') { await init(dbPath); return; } + if (type === 'exec' || type === 'query') { + const rows = []; + db.exec({ sql, bind: bind ?? [], rowMode: 'object', callback: r => rows.push(r) }); + self.postMessage({ type: 'result', id, rows }); + } else if (type === 'execute-long') { + db.exec(sql, { bind: bind ?? [] }); + self.postMessage({ type: 'long-result', id, value: db.changes() }); + } else if (type === 'transaction-begin') { + db.exec('BEGIN'); + self.postMessage({ type: 'result', id, rows: [] }); + } else if (type === 'transaction-end') { + db.exec(successful ? 'COMMIT' : 'ROLLBACK'); + self.postMessage({ type: 'result', id, rows: [] }); + } + } catch (e) { + self.postMessage({ type: 'error', id, message: e.message }); + } +}; +``` + +**Notes**: `resources/` files in `wasmJsMain` are copied to the webpack output directory by the Kotlin Gradle plugin. Verify the worker appears in `kmp/build/dist/wasmJs/productionExecutable/` (R2 risk). The `id` field is a monotonically increasing integer managed by the Kotlin side; it maps responses back to pending Kotlin coroutines. + +--- + +### Story 2.2 — Kotlin Layer (External Declarations + SqlDriver) + +Tasks in order: 2.2.1 → 2.2.2 → 2.2.3 + +#### Task 2.2.1 — Enable `generateAsync = true` in SQLDelight config + +**File**: `kmp/build.gradle.kts` (line ~806) +**Dependencies**: none (should be done before implementing the driver, and immediately followed by running `./gradlew jvmTest testDebugUnitTest` to catch regressions) + +Change: +```kotlin +sqldelight { + databases { + create("SteleDatabase") { + packageName.set("dev.stapler.stelekit.db") + } + } +} +``` + +To: +```kotlin +sqldelight { + databases { + create("SteleDatabase") { + packageName.set("dev.stapler.stelekit.db") + generateAsync.set(true) + } + } +} +``` + +**Risk R1**: This regenerates ALL generated query functions to return suspend-capable `QueryResult`. The JVM `sqlite-driver` and Android `android-driver` already support this (they return `QueryResult.Value` which is compatible when called from a coroutine). Run `./gradlew ciCheck` immediately after this change. If any callers use `executeAsOne()` or `executeAsList()` (synchronous extensions), they must be replaced with `awaitAsOne()` / `awaitAsList()` on all platforms, or use `expect/actual` to keep synchronous on non-browser targets. + +**Migration note**: SQLDelight's `awaitAsList()` / `awaitAsOne()` are suspend extension functions from `coroutines-extensions` — already on the classpath. The generated `Queries` classes remain the same; only `executeAsOne()` signatures change to require a coroutine context. + +#### Task 2.2.2 — Create Kotlin external declarations for worker protocol + +**File**: `kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/SqliteWorkerInterop.kt` (new) +**Dependencies**: Task 2.1.1, Task 2.1.2 + +Declare the JS interop functions needed to create a worker and exchange messages: + +```kotlin +package dev.stapler.stelekit.db + +import kotlin.js.Promise + +// Spawn a module-type Web Worker +internal fun createSqliteWorker(scriptPath: String): JsAny = + js("new Worker(scriptPath, { type: 'module' })") + +// Send a message to the worker +internal fun workerPostMessage(worker: JsAny, message: JsAny): Unit = + js("worker.postMessage(message)") + +// Register the onmessage callback +internal fun workerOnMessage(worker: JsAny, handler: (JsAny) -> Unit): Unit = + js("worker.onmessage = function(e) { handler(e.data) }") + +// Build a plain JS object literal for the message payload +internal fun buildInitMessage(dbPath: String): JsAny = + js("({ type: 'init', dbPath: dbPath })") + +internal fun buildExecMessage(id: Int, sql: String, bindJson: String): JsAny = + js("({ type: 'exec', id: id, sql: sql, bind: JSON.parse(bindJson) })") + +internal fun buildQueryMessage(id: Int, sql: String, bindJson: String): JsAny = + js("({ type: 'query', id: id, sql: sql, bind: JSON.parse(bindJson) })") + +internal fun buildTransactionBeginMessage(id: Int): JsAny = + js("({ type: 'transaction-begin', id: id })") + +internal fun buildTransactionEndMessage(id: Int, successful: Boolean): JsAny = + js("({ type: 'transaction-end', id: id, successful: successful })") + +internal fun buildExecuteLongMessage(id: Int, sql: String, bindJson: String): JsAny = + js("({ type: 'execute-long', id: id, sql: sql, bind: JSON.parse(bindJson) })") + +// Read fields from a response JsAny (worker postMessage data) +internal fun getMessageType(msg: JsAny): String = js("msg.type") +internal fun getMessageId(msg: JsAny): Int = js("msg.id") +internal fun getMessageRows(msg: JsAny): JsAny = js("msg.rows") +internal fun getMessageValue(msg: JsAny): Long = js("BigInt(msg.value)") +internal fun getMessageError(msg: JsAny): String = js("msg.message") +internal fun getMessageBackend(msg: JsAny): String = js("msg.backend") +internal fun getMessageWarning(msg: JsAny): String? = js("msg.warning || null") +internal fun rowsToJsonString(rows: JsAny): String = js("JSON.stringify(rows)") + +// Create a Promise that wraps worker response for a given message id +internal fun createWorkerResponsePromise(worker: JsAny, id: Int): Promise = + js("""new Promise(function(resolve, reject) { + function handler(e) { + var data = e.data; + if ((data.type === 'result' || data.type === 'long-result' || data.type === 'error') && data.id === id) { + worker.removeEventListener('message', handler); + if (data.type === 'error') { reject(new Error(data.message)); } + else { resolve(data); } + } + } + worker.addEventListener('message', handler); + })""") +``` + +**Notes**: Kotlin/WASM requires all JS-boundary values to be `JsAny`. Parameter binding values must be JSON-serialized in Kotlin and parsed in JS (avoids complex `JsAny` array construction). Row results are JSON-stringified in JS and parsed in Kotlin using `kotlinx.serialization`. The `Promise` type is imported from `kotlin.js`. + +#### Task 2.2.3 — Implement `WasmOpfsSqlDriver` + +**File**: `kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/WasmOpfsSqlDriver.kt` (new) +**Dependencies**: Task 2.2.2 + +Implement `app.cash.sqldelight.db.SqlDriver`. Key design decisions: + +- All operations return `QueryResult.AsyncValue { ... }` (requires `generateAsync = true` from 2.2.1). +- A `suspend fun init(dbPath: String)` must be called before `createDriver()` returns — `DriverFactory` is responsible for calling `init` before handing the driver to `GraphManager`. +- An internal `AtomicInteger` provides monotonically increasing message IDs. +- The worker is created once per driver instance. OPFS `opfs-sahpool` holds the db open for the driver lifetime. +- `addListener` / `removeListener` / `notifyListeners` maintain a `MutableMap>` — same pattern as the in-memory driver. + +```kotlin +package dev.stapler.stelekit.db + +import app.cash.sqldelight.Query +import app.cash.sqldelight.Transacter +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlCursor +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.db.SqlPreparedStatement +import kotlinx.coroutines.await +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject + +class WasmOpfsSqlDriver(private val workerScriptPath: String) : SqlDriver { + + private val worker: JsAny = createSqliteWorker(workerScriptPath) + private var nextId = 0 + private val listeners = mutableMapOf>() + var actualBackend: String = "unknown" + private set + + // Must be called once before using the driver + suspend fun init(dbPath: String) { + val readyPromise: Promise = js("""new Promise(function(resolve) { + worker.addEventListener('message', function onReady(e) { + if (e.data.type === 'ready') { + worker.removeEventListener('message', onReady); + resolve(e.data); + } + }); + })""") + workerPostMessage(worker, buildInitMessage(dbPath)) + val readyMsg = readyPromise.await() + actualBackend = getMessageBackend(readyMsg) + val warning = getMessageWarning(readyMsg) + if (warning != null) { + console.warn("[SteleKit] SQLite worker fallback: $warning") + } + } + + private fun nextMsgId(): Int = nextId++ + + override fun execute( + identifier: Int?, + sql: String, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)? + ): QueryResult = QueryResult.AsyncValue { + val id = nextMsgId() + val bindValues = if (binders != null) { + val collector = JsBindCollector() + binders(collector) + collector.toJsonString() + } else "[]" + val promise = createWorkerResponsePromise(worker, id) + workerPostMessage(worker, buildExecuteLongMessage(id, sql, bindValues)) + val resp = promise.await() + getMessageValue(resp) + } + + override fun executeQuery( + identifier: Int?, + sql: String, + mapper: (SqlCursor) -> QueryResult, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)? + ): QueryResult = QueryResult.AsyncValue { + val id = nextMsgId() + val bindValues = if (binders != null) { + val collector = JsBindCollector() + binders(collector) + collector.toJsonString() + } else "[]" + val promise = createWorkerResponsePromise(worker, id) + workerPostMessage(worker, buildQueryMessage(id, sql, bindValues)) + val resp = promise.await() + val rowsJson = rowsToJsonString(getMessageRows(resp)) + val rows = Json.decodeFromString(rowsJson) + val cursor = JsonArraySqlCursor(rows) + mapper(cursor).await() + } + + override fun newTransaction(): QueryResult = QueryResult.AsyncValue { + val id = nextMsgId() + val promise = createWorkerResponsePromise(worker, id) + workerPostMessage(worker, buildTransactionBeginMessage(id)) + promise.await() + WasmTransaction(this) + } + + override fun endTransaction(successful: Boolean): QueryResult = QueryResult.AsyncValue { + val id = nextMsgId() + val promise = createWorkerResponsePromise(worker, id) + workerPostMessage(worker, buildTransactionEndMessage(id, successful)) + promise.await() + Unit + } + + override fun currentTransaction(): Transacter.Transaction? = null + + override fun addListener(vararg queryKeys: String, listener: Query.Listener) { + queryKeys.forEach { key -> listeners.getOrPut(key) { mutableSetOf() }.add(listener) } + } + + override fun removeListener(vararg queryKeys: String, listener: Query.Listener) { + queryKeys.forEach { key -> listeners[key]?.remove(listener) } + } + + override fun notifyListeners(vararg queryKeys: String) { + queryKeys.forEach { key -> listeners[key]?.forEach { it.queryResultsChanged() } } + } + + override fun close() { + // Worker termination is optional; browser GC will handle it + } +} +``` + +Supporting classes needed in the same package or file: + +- `JsBindCollector : SqlPreparedStatement` — collects bound values into a JSON-serializable list. +- `JsonArraySqlCursor : SqlCursor` — wraps a `JsonArray` of `JsonObject` rows, implementing `getString`, `getLong`, `getDouble`, `getBytes`, `getBoolean` by column index (requires knowing column names — use ordered index from SQLDelight's generated queries which always use positional columns). +- `WasmTransaction : Transacter.Transaction` — delegates `commit()`/`rollback()` back to the driver. + +**Important**: `JsonArraySqlCursor` must handle SQLDelight's positional column binding. The worker returns rows as `{ columnName: value }` objects. The cursor needs a column-name-to-index mapping derived from the first row's key order. This is the most fiddly implementation detail — test thoroughly with the migration queries. + +--- + +### Story 2.3 — Integration (DriverFactory + FileSystem + Main.kt) + +Tasks in order: 2.3.1 → 2.3.2 → 2.3.3 (parallel) → 2.3.4 + +#### Task 2.3.1 — Wire `DriverFactory.js.kt` to try OPFS then fall back + +**File**: `kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/DriverFactory.js.kt` +**Dependencies**: Task 2.2.3 + +Replace the `UnsupportedOperationException` stub: + +```kotlin +package dev.stapler.stelekit.db + +import app.cash.sqldelight.db.SqlDriver + +actual class DriverFactory actual constructor() { + actual fun init(context: Any) {} + + actual fun createDriver(jdbcUrl: String): SqlDriver { + // Caller must use createDriverAsync() on wasmJs — this path should not be reached + throw UnsupportedOperationException("Use createDriverAsync() on wasmJs") + } + + // OPFS path extracted from the fake JDBC URL: "jdbc:sqlite:stelekit-graph-" + actual fun getDatabaseUrl(graphId: String): String = "jdbc:sqlite:stelekit-graph-$graphId" + actual fun getDatabaseDirectory(): String = "/stelekit" + + // Async entry point — called from browser/Main.kt before GraphManager.addGraph() + suspend fun createDriverAsync(graphId: String): SqlDriver { + val opfsPath = "/stelekit/graph-${graphId}.sqlite3" + val driver = WasmOpfsSqlDriver(workerScriptPath = "./sqlite-stelekit-worker.js") + driver.init(opfsPath) + // Create schema (idempotent) and run migrations + SteleDatabase.Schema.create(driver).await() + MigrationRunner.applyAll(driver) + return driver + } +} + +actual val defaultDatabaseUrl: String + get() = "jdbc:sqlite:stelekit" +``` + +**Notes**: The JDBC URL prefix is meaningless in WASM — the graphId is extracted directly. `createDriverAsync` is a new non-`actual` function specific to the wasmJs implementation; it does not need an `expect` declaration. + +**Worker script path**: The literal `"./sqlite-stelekit-worker.js"` assumes the worker file is served at the same path as `index.html`. Verify this matches the actual webpack output path after the first build (R2 risk). + +**MigrationRunner**: Verify `MigrationRunner.applyAll(driver)` works with an async driver. If it calls `driver.execute(...)` synchronously (expecting `QueryResult.Value`), it will need to be wrapped in a coroutine or refactored to use `.await()`. Inspect `MigrationRunner.kt` at implementation time. + +#### Task 2.3.2 — Implement OPFS-backed `PlatformFileSystem` + +**File**: `kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/PlatformFileSystem.kt` +**Dependencies**: none (parallel with 2.3.1 after 2.2.3 completes, but can be worked independently) + +Replace the stub with an **Option B pre-load implementation** (in-memory cache, async OPFS write-through): + +```kotlin +package dev.stapler.stelekit.platform + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +actual class PlatformFileSystem actual constructor() : FileSystem { + private val homeDir = "/stelekit" + private val cache = mutableMapOf() // path → content + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + // Call once at startup in a suspend context before passing to GraphManager + suspend fun preload(graphPath: String) { + // List files in OPFS at graphPath and load them into cache + // Uses JS interop calls to navigator.storage.getDirectory() + // See OpfsFileSystemInterop.kt for JS interop helpers + loadDirectory(graphPath) + } + + // Synchronous reads from cache (GraphLoader calls these) + actual override fun readFile(path: String): String? = cache[path] + actual override fun fileExists(path: String): Boolean = cache.containsKey(path) + actual override fun listFiles(path: String): List = + cache.keys.filter { it.startsWith(path) && !it.removePrefix(path).drop(1).contains('/') } + actual override fun listDirectories(path: String): List = emptyList() // deferred + + // Sync write: update cache immediately, write to OPFS asynchronously + actual override fun writeFile(path: String, content: String): Boolean { + cache[path] = content + scope.launch { opfsWriteFile(path, content) } + return true + } + + actual override fun getDefaultGraphPath(): String = homeDir + actual override fun expandTilde(path: String): String = + if (path.startsWith("~")) path.replaceFirst("~", homeDir) else path + actual override fun directoryExists(path: String): Boolean = true // optimistic + actual override fun createDirectory(path: String): Boolean = true // OPFS dirs created lazily + actual override fun deleteFile(path: String): Boolean { + cache.remove(path) + scope.launch { opfsDeleteFile(path) } + return true + } + actual override fun pickDirectory(): String? = null + actual override suspend fun pickDirectoryAsync(): String? = null + actual override fun getLastModifiedTime(path: String): Long? = null + actual override fun displayNameForPath(path: String): String = path.substringAfterLast('/') + actual override fun getDownloadsPath(): String = homeDir +} +``` + +A companion file `OpfsFileSystemInterop.kt` (same package) provides: +- `suspend fun loadDirectory(path: String)` — uses `js("navigator.storage.getDirectory()")` wrapped as `Promise` to walk the OPFS directory tree and populate the cache. +- `suspend fun opfsWriteFile(path: String, content: String)` — async OPFS write via `createWritable()`. +- `suspend fun opfsDeleteFile(path: String)` — OPFS `removeEntry()`. + +**Notes**: The cache pre-load happens once at startup in `browser/Main.kt` before `GraphManager.addGraph()`. Writes are fire-and-forget to the background scope (acceptable durability for a note-taking app — crashes between cache update and OPFS flush lose at most one debounce window of edits, consistent with the existing JVM behavior). + +#### Task 2.3.3 — Update `browser/Main.kt` to use SQLDELIGHT backend + +**File**: `kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/browser/Main.kt` +**Dependencies**: Task 2.3.1 (createDriverAsync), Task 2.3.2 (PlatformFileSystem.preload) + +Replace the `CanvasBasedWindow` body. The key challenge: `CanvasBasedWindow` takes a `@Composable` lambda — suspend calls are not allowed there. The OPFS initialization must happen before the Composable runs. + +Approach: initialize OPFS in a coroutine at the top of `main()`, then start `CanvasBasedWindow` after the driver is ready: + +```kotlin +@OptIn(ExperimentalComposeUiApi::class) +fun main() { + // Initialize OPFS outside of the Composable tree + val scope = MainScope() // or CoroutineScope(Dispatchers.Default) + val graphId = "default" + + scope.launch { + val fileSystem = PlatformFileSystem() + fileSystem.preload("/stelekit/$graphId") + + val driverFactory = DriverFactory() + val driver = try { + driverFactory.createDriverAsync(graphId) + } catch (e: Exception) { + console.warn("[SteleKit] OPFS driver init failed, using IN_MEMORY: ${e.message}") + null + } + + val backend = if (driver != null) GraphBackend.SQLDELIGHT else GraphBackend.IN_MEMORY + val graphManager = GraphManager( + platformSettings = PlatformSettings(), + driverFactory = driverFactory, + fileSystem = fileSystem, + defaultBackend = backend, + ) + + val graphPath = "/stelekit/$graphId" + graphManager.addGraph(graphPath) + + // Signal to Playwright that the app is ready (for e2e tests) + js("window.__stelekit_ready = true") + + CanvasBasedWindow(canvasElementId = "ComposeTarget") { + StelekitApp( + fileSystem = fileSystem, + graphPath = graphPath, + graphManager = graphManager, + ) + } + } +} +``` + +**Notes**: `GraphManager.addGraph()` is currently called inside the Composable via navigation. Check if it can be called outside — if `GraphManager` requires the Compose lifecycle, this pattern needs adjustment. `window.__stelekit_ready = true` is used by Playwright tests to know initialization is complete. `DemoFileSystem` is no longer used as the primary file system (can be kept as a fallback if OPFS init fails). + +#### Task 2.3.4 — Verify Webpack copies `sqlite3.wasm` and worker file + +**File**: build verification (no code change, but documents what to check) +**Dependencies**: Tasks 2.1.1, 2.1.2 + +After `./gradlew :kmp:wasmJsBrowserDistribution -PenableJs=true`, verify: + +1. `kmp/build/dist/wasmJs/productionExecutable/sqlite3.wasm` — the SQLite binary (from npm package). +2. `kmp/build/dist/wasmJs/productionExecutable/sqlite-stelekit-worker.js` — the worker script. + +If either is missing: +- For `sqlite3.wasm`: may need a webpack copy plugin config. Check if `@sqlite.org/sqlite-wasm` package uses `import.meta.url` asset references (webpack 5 handles these automatically as asset modules). +- For the worker script: verify `resources/` files are copied. The Kotlin Gradle plugin should copy `wasmJsMain/resources/` to the output directory — if not, add an explicit `Copy` Gradle task. + +If the worker path resolves differently (e.g., `composeResources/sqlite-stelekit-worker.js`), update the `workerScriptPath` literal in `DriverFactory.js.kt`. + +--- + +## Epic 3: E2E Persistence Tests + +**Goal**: Playwright test suite verifies OPFS persistence and test isolation. + +### Story 3.1 — Persistence test and isolation + +**Dependencies**: Epic 2 complete + +#### Task 3.1.1 — Add OPFS persistence test to `demo.spec.ts` + +**File**: `e2e/tests/demo.spec.ts` +**Dependencies**: Epic 2 complete (OPFS driver working) + +Add a new test after the existing canvas-paint test: + +```typescript +test('SteleKit OPFS: data persists across page reload', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', err => errors.push(err.message)); + + await page.goto('/'); + + // Wait for WASM init + OPFS driver ready + await page.waitForFunction( + () => (window as any).__stelekit_ready === true, + { timeout: 30_000 }, + ); + + // Reload — OPFS data survives within the same Playwright browser context + await page.reload(); + + await page.waitForFunction( + () => (window as any).__stelekit_ready === true, + { timeout: 30_000 }, + ); + + // Assert OPFS directory exists (proves the driver wrote to OPFS, not just memory) + const hasOpfsData = await page.evaluate(async () => { + try { + const root = await navigator.storage.getDirectory(); + await root.getDirectoryHandle('stelekit', { create: false }); + return true; + } catch { + return false; + } + }); + expect(hasOpfsData, 'OPFS stelekit directory must exist after app init').toBe(true); + + expect(errors, `Uncaught JS errors: ${errors.join(' | ')}`).toHaveLength(0); +}); +``` + +**Notes**: This test uses `page.reload()` within the same browser context — OPFS data survives within the same context (Playwright does not clear origin storage between `goto`/`reload` by default). The `__stelekit_ready` flag is set in `browser/Main.kt` (Task 2.3.3) after the OPFS driver is initialized. + +#### Task 3.1.2 — Add OPFS storage clearing in `beforeEach` for test isolation + +**File**: `e2e/tests/demo.spec.ts` +**Dependencies**: Task 3.1.1 + +Add a `beforeEach` hook that clears the OPFS `stelekit` directory before each test run to prevent test-order dependencies: + +```typescript +test.beforeEach(async ({ page }) => { + await page.goto('/'); + // Clear OPFS stelekit directory for isolation + await page.evaluate(async () => { + try { + const root = await navigator.storage.getDirectory(); + await root.removeEntry('stelekit', { recursive: true }); + } catch { + // Directory may not exist on first run — ignore + } + }); +}); +``` + +**Notes**: This must run after `page.goto('/')` because OPFS is only accessible from an origin context (requires the page to be loaded). Placing it before the persistence test means the persistence test starts with a clean slate and verifies that the app creates the OPFS directory from scratch — which is the correct behavior for first launch. + +--- + +## Sequencing Summary + +``` +Epic 1 (no blockers, can be done any time): + 1.1.1 — scripts/serve-web.sh + +Epic 2: + Parallel: 2.1.1 (npm dep), 2.1.2 (sqlite-stelekit-worker.js) + Then 2.2.1 (generateAsync=true) — IMMEDIATELY run ciCheck + Then 2.2.2 (external declarations) — needs 2.1.1, 2.1.2 done + Then 2.2.3 (WasmOpfsSqlDriver) — needs 2.2.2 + Then 2.3.1 (DriverFactory) — needs 2.2.3 + 2.3.2 (PlatformFileSystem) — needs 2.2.3 (can parallel with 2.3.1) + 2.3.3 (browser/Main.kt) — needs 2.3.1 and 2.3.2 + Then 2.3.4 (build verification) — needs 2.3.3 + +Epic 3: + 3.1.1 (persistence test) — needs Epic 2 + 3.1.2 (beforeEach isolation) — needs 3.1.1 +``` + +--- + +## File Map + +| File | Status | Task | +|------|--------|------| +| `scripts/serve-web.sh` | New | 1.1.1 | +| `kmp/build.gradle.kts` | Modify (npm dep + generateAsync) | 2.1.1, 2.2.1 | +| `kmp/src/wasmJsMain/resources/sqlite-stelekit-worker.js` | New | 2.1.2 | +| `kmp/src/wasmJsMain/kotlin/.../db/SqliteWorkerInterop.kt` | New | 2.2.2 | +| `kmp/src/wasmJsMain/kotlin/.../db/WasmOpfsSqlDriver.kt` | New | 2.2.3 | +| `kmp/src/wasmJsMain/kotlin/.../db/DriverFactory.js.kt` | Modify | 2.3.1 | +| `kmp/src/wasmJsMain/kotlin/.../platform/PlatformFileSystem.kt` | Modify | 2.3.2 | +| `kmp/src/wasmJsMain/kotlin/.../platform/OpfsFileSystemInterop.kt` | New | 2.3.2 | +| `kmp/src/wasmJsMain/kotlin/.../browser/Main.kt` | Modify | 2.3.3 | +| `e2e/tests/demo.spec.ts` | Modify | 3.1.1, 3.1.2 | + +Kotlin package path abbreviation: `dev.stapler.stelekit` + +--- + +## Technology Choices + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| OPFS VFS | `opfs-sahpool` | 3-4x faster than `opfs`; no SAB required; single-tab is sufficient; supports Safari 16.4+ (target is 18.2+) | +| SQLite API | Direct `sqlite3InitModule` | `Worker1/Promiser` API deprecated 2026-04-15; direct API is the upstream recommendation | +| npm package | `@sqlite.org/sqlite-wasm@3.46.1` (bundled) | CDN introduces COEP/CORP risk with `require-corp`; local bundle is safer | +| SqlDriver approach | Custom `WasmOpfsSqlDriver` with `QueryResult.AsyncValue` | No `web-worker-driver-wasm-js` at 2.3.2; 2.1.0 version mismatch risk is too high | +| `generateAsync` | `true` (global) | Correct long-term direction; JVM/Android drivers are compatible with async callers | +| FileSystem bridge | Option B (in-memory pre-load cache) | Avoids breaking the `FileSystem` sync interface; consistent with `DemoFileSystem` pattern; simpler than suspend variants across all platforms | +| Worker spawning | `js("new Worker(path, { type: 'module' })")` | No native Kotlin/WASM Web Worker API exists; ES module workers required for `import` in worker | +| Playwright OPFS clearing | `page.evaluate` + `removeEntry` in `beforeEach` | Must clear within origin context; Playwright's standard context isolation does not clear OPFS | diff --git a/project_plans/stelekit-web-opfs/implementation/validation.md b/project_plans/stelekit-web-opfs/implementation/validation.md new file mode 100644 index 00000000..c8f12822 --- /dev/null +++ b/project_plans/stelekit-web-opfs/implementation/validation.md @@ -0,0 +1,284 @@ +# Validation Plan: SteleKit Web — OPFS Durable Storage & Local Dev + +## Coverage Summary + +| Test Type | Count | +|-----------|-------| +| Build tests | 2 | +| E2E browser tests | 7 | +| **Total** | **9** | + +| Requirement | Tests Covering It | +|-------------|-------------------| +| F1 — OPFS SQLite Driver | E2E-02, E2E-03, E2E-04 | +| F2 — SQLDELIGHT backend in browser Main | E2E-02, E2E-03 | +| F3 — Local dev script | BUILD-02, E2E-06 | +| F4 — CI OPFS persistence test | E2E-02, E2E-03 | +| AC1 — serve-web.sh builds and serves | BUILD-02 | +| AC2 — Note persists after reload | E2E-02 | +| AC3 — Playwright persistence test passes in CI | E2E-02, E2E-03 | +| AC4 — wasmJsBrowserDistribution succeeds | BUILD-01 | +| AC5 — IN_MEMORY fallback with console warning | E2E-04 | + +Requirements coverage: **5/5 functional requirements, 5/5 acceptance criteria** (100%). + +--- + +## Build Tests + +### BUILD-01 + +| Field | Value | +|-------|-------| +| **ID** | BUILD-01 | +| **Requirement refs** | AC4 | +| **Test type** | Build (Gradle) | +| **Description** | Run `./gradlew :kmp:wasmJsBrowserDistribution -PenableJs=true` and assert exit code 0. | +| **Pass criteria** | Gradle task exits 0. The output directory `kmp/build/dist/wasmJs/productionExecutable/` exists and contains `index.html`, `*.wasm`, and `*.js` files. No build errors or unresolved symbol errors in stderr. | +| **Implementation note** | This is already exercised implicitly by the Playwright CI job (which starts `node server.mjs` pointing at that directory). Add an explicit step in `.github/workflows/` (or verify the existing CI build step) that runs the Gradle command in isolation so a compile failure is caught before Playwright runs. | + +--- + +### BUILD-02 + +| Field | Value | +|-------|-------| +| **ID** | BUILD-02 | +| **Requirement refs** | F3, AC1 | +| **Test type** | Build (shell) | +| **Description** | Assert that `scripts/serve-web.sh` exists, is executable (mode includes `x`), and contains the required build and serve invocations. | +| **Pass criteria** | `test -x scripts/serve-web.sh` exits 0. File contains `wasmJsBrowserDistribution` and `server.mjs` (or equivalent serve command). A dry-run invocation with a mocked Gradle (or `--dry-run` flag) prints a localhost URL string to stdout. | +| **Implementation note** | Verify in CI as a pre-check step (`ls -la scripts/serve-web.sh && head -20 scripts/serve-web.sh`). Full integration can be verified manually or in a separate non-headless CI job. | + +--- + +## E2E Browser Tests + +All E2E tests run via Playwright against the `chromium` project defined in `e2e/playwright.config.ts`. They target `http://localhost:8787` served by `node server.mjs`. + +### Shared Helper: `waitForWasmReady` + +All persistence tests reuse the existing canvas-resize check from `demo.spec.ts` as the "WASM initialized" signal: + +```typescript +async function waitForWasmReady(page: Page, timeout = 30_000) { + await page.waitForFunction( + () => { + const c = document.getElementById('ComposeTarget') as HTMLCanvasElement | null; + return (c?.width ?? 0) > 300; + }, + { timeout }, + ); +} +``` + +### Shared Helper: `clearOpfsStorage` + +Used in `beforeEach` for all OPFS tests to prevent state bleed between test runs: + +```typescript +async function clearOpfsStorage(page: Page) { + await page.evaluate(async () => { + try { + const root = await navigator.storage.getDirectory(); + await root.removeEntry('stelekit', { recursive: true }); + } catch { + // directory may not exist yet — ignore + } + }); +} +``` + +Call `clearOpfsStorage` BEFORE navigating to `/` (before the app boots) so the OPFS directory is absent when the driver initializes. + +--- + +### E2E-01 (existing — extend, do not replace) + +| Field | Value | +|-------|-------| +| **ID** | E2E-01 | +| **Requirement refs** | AC3 (baseline smoke) | +| **Test type** | E2E browser | +| **Description** | Existing test in `e2e/tests/demo.spec.ts`: canvas initializes and Compose paints at least one WebGL frame; no uncaught JS exceptions on startup. | +| **Pass criteria** | `canvas#ComposeTarget` is attached within 10 s; canvas width > 300 px within 30 s; center pixel alpha > 0; zero `pageerror` events. (Unchanged from current spec.) | +| **Implementation note** | This test must continue to pass after OPFS integration. If the OPFS driver initialization emits benign console messages (e.g., from `sqlite3InitModule`), ensure they do not reach `pageerror` (they should not — `console.*` calls are not uncaught exceptions). | + +--- + +### E2E-02 + +| Field | Value | +|-------|-------| +| **ID** | E2E-02 | +| **Requirement refs** | F1, F2, F4, AC2, AC3 | +| **Test type** | E2E browser (persistence) | +| **Description** | OPFS data written during one page load is present after `page.reload()` within the same Playwright browser context. | +| **Pass criteria** | After first load and WASM init: (1) the OPFS directory `/stelekit` exists in navigator storage; (2) after `page.reload()` and re-init, the directory still exists and contains at least one `.sqlite` (or VFS pool) file, confirming the driver opened and wrote the database rather than falling back to IN_MEMORY. | +| **Implementation note** | Use `page.evaluate()` to probe OPFS via `navigator.storage.getDirectory()` both before and after reload. Do NOT use a persistent Playwright context (`userDataDir`) — `page.reload()` within the default ephemeral context preserves OPFS within the same browser session, which is sufficient. Clear OPFS in `beforeEach` using `clearOpfsStorage` before the initial `page.goto('/')`. | + +```typescript +test('OPFS persistence: data survives page.reload()', async ({ page }) => { + await clearOpfsStorage(page); + await page.goto('/'); + await waitForWasmReady(page); + + // Verify OPFS directory was created on first load + const dirExistsAfterFirstLoad = await page.evaluate(async () => { + try { + const root = await navigator.storage.getDirectory(); + await root.getDirectoryHandle('stelekit', { create: false }); + return true; + } catch { return false; } + }); + expect(dirExistsAfterFirstLoad).toBe(true); + + await page.reload(); + await waitForWasmReady(page); + + // Verify OPFS directory and at least one file survives reload + const persistedAfterReload = await page.evaluate(async () => { + try { + const root = await navigator.storage.getDirectory(); + const dir = await root.getDirectoryHandle('stelekit', { create: false }); + const entries: string[] = []; + for await (const [name] of (dir as any).entries()) entries.push(name); + return entries.length > 0; + } catch { return false; } + }); + expect(persistedAfterReload).toBe(true); +}); +``` + +--- + +### E2E-03 + +| Field | Value | +|-------|-------| +| **ID** | E2E-03 | +| **Requirement refs** | F1, F4, AC3 | +| **Test type** | E2E browser (performance) | +| **Description** | OPFS database open completes within 3 seconds on first launch (non-functional requirement: "Initial OPFS database open must complete within 3 seconds"). | +| **Pass criteria** | Time from `page.goto('/')` to `waitForWasmReady` completion is less than 3000 ms beyond the time for WASM binary loading (measured as: `waitForWasmReady` resolves within 33 s total, where 30 s covers WASM load and 3 s covers OPFS init). More precisely: expose a `window.__stelekit_opfs_ready_ms` timestamp from Kotlin JS interop; assert it minus `window.__stelekit_wasm_start_ms` is < 3000. If those markers are unavailable, assert that `waitForWasmReady` resolves within 33 s (30 s WASM + 3 s OPFS budget). | +| **Implementation note** | The simplest implementation: add `window.__stelekit_ready = true` from Kotlin via `js("window.__stelekit_ready = true")` after the driver initializes, then use `page.waitForFunction(() => window.__stelekit_ready === true, { timeout: 33_000 })`. Compare timestamps if more precision is required. A soft assertion (warning, not failure) is acceptable for CI flakiness reasons on slow machines. | + +--- + +### E2E-04 + +| Field | Value | +|-------|-------| +| **ID** | E2E-04 | +| **Requirement refs** | F1, AC5 | +| **Test type** | E2E browser (fallback) | +| **Description** | When OPFS is blocked (simulated via `page.addInitScript`), the app falls back to IN_MEMORY and emits a console warning containing "OPFS unavailable" or equivalent. No uncaught exception. Canvas still initializes. | +| **Pass criteria** | A `console.warn` (or `console.error`) message matching `/OPFS (unavailable|blocked|failed)/i` is captured. Zero `pageerror` events. Canvas width > 300 px within 30 s (app still loads). | +| **Implementation note** | Block OPFS before page load using `addInitScript`: | + +```typescript +test('OPFS fallback: IN_MEMORY used when OPFS is blocked', async ({ page }) => { + const consoleWarnings: string[] = []; + page.on('console', msg => { + if (msg.type() === 'warn' || msg.type() === 'error') { + consoleWarnings.push(msg.text()); + } + }); + const errors: string[] = []; + page.on('pageerror', err => errors.push(err.message)); + + // Override navigator.storage.getDirectory to throw before the page loads + await page.addInitScript(() => { + Object.defineProperty(navigator, 'storage', { + value: { + getDirectory: () => Promise.reject(new DOMException('Mocked OPFS unavailable', 'NotSupportedError')), + estimate: () => Promise.resolve({ usage: 0, quota: 0 }), + }, + configurable: true, + }); + }); + + await page.goto('/'); + await waitForWasmReady(page); + + expect(errors, `Uncaught JS errors: ${errors.join(' | ')}`).toHaveLength(0); + const hasOPFSWarning = consoleWarnings.some(w => /OPFS.*(unavailable|blocked|failed|error)/i.test(w)); + expect(hasOPFSWarning, `Expected OPFS fallback warning in: ${consoleWarnings.join(' | ')}`).toBe(true); +}); +``` + +--- + +### E2E-05 + +| Field | Value | +|-------|-------| +| **ID** | E2E-05 | +| **Requirement refs** | F1 (error handling), AC5 | +| **Test type** | E2E browser (quota error) | +| **Description** | When OPFS write fails with `QuotaExceededError` (simulated), the app handles it gracefully — no uncaught exception, canvas still renders. | +| **Pass criteria** | Zero `pageerror` events. Canvas width > 300 px. A console message (warn or error) is emitted. | +| **Implementation note** | Simulate quota failure by overriding `FileSystemSyncAccessHandle.write` to throw `QuotaExceededError` in an `addInitScript`. This is a lower-priority test; skip in initial CI if simulation is too complex and cover it via unit test of the error-handling path instead. | + +--- + +### E2E-06 + +| Field | Value | +|-------|-------| +| **ID** | E2E-06 | +| **Requirement refs** | F3, AC1 | +| **Test type** | E2E browser (smoke via script) | +| **Description** | `scripts/serve-web.sh` is used as the `webServer.command` in Playwright config for a separate "serve-web" project, verifying the full dev workflow end-to-end: script builds WASM target and starts server, then existing E2E-01 smoke test passes against that server. | +| **Pass criteria** | Playwright webServer starts within 120 s using `scripts/serve-web.sh`. Canvas smoke test (E2E-01 assertions) passes. This test is marked `@slow` and run only in the optional `serve-web` Playwright project (not in the default `chromium` project). | +| **Implementation note** | Add a second Playwright project in `playwright.config.ts`: | + +```typescript +{ + name: 'serve-web-script', + use: { ...devices['Desktop Chrome'] }, + testMatch: '**/demo.spec.ts', // reuse E2E-01 + // Override webServer for this project only if Playwright supports per-project servers + // (Playwright 1.38+); otherwise use a separate config file: e2e/playwright.serve-web.config.ts +} +``` + +If per-project webServer is not feasible, create `e2e/playwright.serve-web.config.ts` with `webServer.command = '../scripts/serve-web.sh'` and run it as a separate CI step. + +--- + +### E2E-07 + +| Field | Value | +|-------|-------| +| **ID** | E2E-07 | +| **Requirement refs** | F1 (multi-tab exclusion), pitfall P3 | +| **Test type** | E2E browser (multi-tab lock) | +| **Description** | Opening a second browser tab against the same origin while the first tab has OPFS locked via `opfs-sahpool` does not cause a crash or `pageerror` in either tab. The second tab should degrade gracefully (either fall back to IN_MEMORY or show an error message). | +| **Pass criteria** | Zero `pageerror` events in either tab. At least one tab renders a canvas. | +| **Implementation note** | Open two pages in the same Playwright browser context: `const page2 = await context.newPage(); await page2.goto('/');`. This is a lower-priority test. Mark as `@skip` in initial implementation and revisit once the architecture decision for multi-tab behavior is finalized (see pitfall P3: `opfs-sahpool` holds exclusive lock). | + +--- + +## Pitfall Coverage + +The following pitfalls from `research/04-pitfalls.md` are exercised by the test suite: + +| Pitfall | ID | Covered By | +|---------|----|------------| +| P1: OPFS sync access is worker-only | Yes | E2E-02, E2E-03 (driver must use worker architecture to pass) | +| P2: Async OPFS API is slow | Partial | E2E-03 (3s budget validates we use sahpool, not async) | +| P3: opfs-sahpool exclusive lock | Yes | E2E-07 (skip until arch decided) | +| P4: OPFS paths are logical, not filesystem | N/A | Not testable via Playwright | +| P5: Quota exceeded | Yes | E2E-05 | +| P6: Worker1/Promiser deprecated | N/A | Architecture choice — enforced via code review, not test | + +--- + +## CI Integration Notes + +1. **Order of execution**: BUILD-01 → BUILD-02 → E2E-01 through E2E-05 (E2E-06 and E2E-07 are optional/skipped initially). +2. **OPFS in Playwright CI**: Playwright's bundled Chromium supports OPFS. No additional flags are needed. The `server.mjs` COOP/COEP headers are already set. +3. **Test isolation**: All OPFS tests call `clearOpfsStorage` in `beforeEach` before navigating. This prevents state leakage between test runs in the same Playwright browser context. +4. **Timeout budget**: Each E2E test has a 60 s total budget (per `playwright.config.ts`). WASM load claims up to 30 s; OPFS init claims up to 3 s; assertions claim the remainder. +5. **Flakiness mitigation**: The `waitForWasmReady` function uses `waitForFunction` with polling, not a fixed `sleep`. The OPFS directory-existence check uses `page.evaluate` with async/await, which is stable. diff --git a/project_plans/stelekit-web-opfs/requirements.md b/project_plans/stelekit-web-opfs/requirements.md new file mode 100644 index 00000000..829c9f26 --- /dev/null +++ b/project_plans/stelekit-web-opfs/requirements.md @@ -0,0 +1,73 @@ +# Requirements: SteleKit Web — OPFS Durable Storage & Local Dev + +## Problem Statement + +The SteleKit wasmJs build runs in the browser using an in-memory `DemoFileSystem` with hard-coded content and an `IN_MEMORY` repository backend. Data is lost on every page reload. The project plan (see `DriverFactory.js.kt:9`) marks this as "Phase B" — replace with a real `@sqlite.org/sqlite-wasm` driver backed by OPFS. Additionally, there is no single command to build and open the web app locally; developers must know to run Gradle + a separate Node server manually. + +## Goals + +1. **Durable storage**: Replace `IN_MEMORY` backend with OPFS-backed SQLite so graph data persists across browser sessions. +2. **Local dev convenience**: Provide a single command (script or Gradle task) that builds the wasmJs target and opens a local preview. +3. **Cross-platform verification**: The web app must work on Chrome 119+, Firefox 120+, Safari 18.2+, as stated in `index.html`. + +## Out of Scope (this iteration) + +- File System Access API folder picker (user's own .md files — deferred to follow-up) +- IndexedDB fallback (OPFS is available in all target browsers since 2023) +- PWA / offline caching + +## Functional Requirements + +### F1 — OPFS SQLite Driver +- `DriverFactory.js.kt` must return a working `SqlDriver` backed by `@sqlite.org/sqlite-wasm` using the OPFS async VFS. +- The driver must create/open a per-graph SQLite database file in OPFS (`/stelekit/.sqlite`). +- SQLDelight-generated queries must work without modification. +- On first launch (no existing OPFS file) the app must bootstrap an empty graph and show today's journal page. + +### F2 — Browser Main using SQLDELIGHT backend +- `browser/Main.kt` must switch `defaultBackend` from `GraphBackend.IN_MEMORY` to `GraphBackend.SQLDELIGHT` once the driver is available. +- A real `PlatformFileSystem` (OPFS-backed) must replace `DemoFileSystem` for file reads/writes. + +### F3 — Local dev script +- A shell script `scripts/serve-web.sh` (or equivalent Gradle task) that: + 1. Builds `./gradlew :kmp:wasmJsBrowserDistribution -PenableJs=true` + 2. Starts `node e2e/server.mjs` pointing at the build output + 3. Prints the local URL +- Must work on macOS and Linux. + +### F4 — CI coverage of OPFS path +- The existing Playwright e2e test must be extended (or a new test added) to verify that data written in one "session" (page load) is visible after a reload — i.e., OPFS persistence is actually exercised. + +## Non-Functional Requirements + +- **Performance**: Initial OPFS database open must complete within 3 seconds on a modern desktop browser. +- **Compatibility**: OPFS async VFS is required; Chromium, Firefox, and Safari all support it since late 2022. +- **Security**: OPFS is origin-scoped; no cross-origin leakage risk. +- **Error handling**: If OPFS is unavailable (e.g., private browsing with restricted storage), fall back to `IN_MEMORY` with a console warning. + +## Constraints + +- Kotlin/WASM JS interop is used via `@JsModule` / `external` declarations — no TypeScript build step. +- SQLDelight 2.3.2 is already on the classpath; no version upgrade. +- The `@sqlite.org/sqlite-wasm` npm package must be bundled with the WASM distribution (or loaded via CDN with integrity hash). +- COOP/COEP headers are already set by `server.mjs` and GitHub Pages — `SharedArrayBuffer` is available. + +## Acceptance Criteria + +| ID | Criterion | +|----|-----------| +| AC1 | `./scripts/serve-web.sh` builds the WASM target and serves it locally in < 2 commands | +| AC2 | Opening the app at `http://localhost:8787`, typing a note, and reloading shows the note still present | +| AC3 | The Playwright e2e suite includes a persistence test that passes in CI | +| AC4 | `./gradlew :kmp:wasmJsBrowserDistribution -PenableJs=true` succeeds without errors | +| AC5 | The app falls back to IN_MEMORY (with a console warning) if OPFS is blocked | + +## Current State Mapping + +| File | Current State | Required Change | +|------|--------------|-----------------| +| `kmp/src/wasmJsMain/.../db/DriverFactory.js.kt` | Throws UnsupportedOperationException | Return OPFS-backed SqlDriver | +| `kmp/src/wasmJsMain/.../browser/Main.kt` | Uses `IN_MEMORY` + `DemoFileSystem` | Use `SQLDELIGHT` + OPFS FileSystem | +| `kmp/src/wasmJsMain/.../platform/DemoFileSystem.kt` | Hard-coded demo content | Keep for offline fallback; add OPFS-based impl | +| `e2e/tests/demo.spec.ts` | Verifies canvas renders | Add OPFS persistence assertion | +| `scripts/serve-web.sh` | Does not exist | Create | diff --git a/project_plans/stelekit-web-opfs/research/01-stack.md b/project_plans/stelekit-web-opfs/research/01-stack.md new file mode 100644 index 00000000..c0d63bec --- /dev/null +++ b/project_plans/stelekit-web-opfs/research/01-stack.md @@ -0,0 +1,164 @@ +# Agent 1 — Stack Research: OPFS SQLite for Kotlin/WASM + +## 1. `@sqlite.org/sqlite-wasm` and OPFS VFS Options + +### Package Identity +- npm package: `@sqlite.org/sqlite-wasm` +- GitHub: https://github.com/sqlite/sqlite-wasm +- This is the official SQLite project's WASM build, not a third-party wrapper. + +### Available OPFS VFS Implementations + +The official package exposes three OPFS-capable VFS implementations: + +| VFS Name | Requires SAB? | Multi-tab? | Performance | Notes | +|---|---|---|---|---| +| `opfs` | **Yes** (SharedArrayBuffer + Atomics) | Yes (single write, concurrent reads) | Good | Uses `sqlite3-opfs-async-proxy.js`; requires COOP/COEP | +| `opfs-sahpool` (OPFSSAHPool) | **No** | No (exclusive lock) | **Best** (3–4× faster than `opfs`) | Works without SAB; released in SQLite 3.43.0 | +| `opfs-wl` | Yes | Yes | Good | Write-lock variant | + +### Which VFS to Use for SteleKit + +**Recommendation: `opfs-sahpool` (OPFSSAHPool VFS)** + +Reasoning: +- The requirements file notes that COOP/COEP headers ARE already set (server.mjs sets them), so SAB IS available. However: +- `opfs-sahpool` is 3–4× faster and is the recommended default for single-tab applications. +- SteleKit is inherently single-tab (one open graph at a time); multi-tab concurrency is not a requirement. +- `opfs-sahpool` does NOT require SAB at all, which simplifies the fallback story. +- The Worker1/Promiser API was **deprecated on 2026-04-15** — do not build on it for new code. + +**CRITICAL NOTE**: Both `opfs` and `opfs-sahpool` require SQLite to run inside a **Web Worker**. The synchronous `FileSystemSyncAccessHandle` API is blocked on the main thread. This is the single most important architectural constraint. + +### opfs-sahpool Initialization (JS-side) + +```js +import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; + +// Must run inside a Web Worker +const sqlite3 = await sqlite3InitModule({ print: console.log, printErr: console.error }); +const poolUtil = await sqlite3.installOpfsSAHPoolVfs({ + name: 'opfs-sahpool', // VFS name + directory: '/stelekit', // OPFS subdirectory + initialCapacity: 6, // pre-allocated file handles + clearOnInit: false, // preserve data across page loads +}); +const db = new poolUtil.OpfsSAHPoolDb('/stelekit/mydb.sqlite3'); +db.exec('CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT)'); +``` + +The `opfs` (async proxy) VFS initialization is similar but requires `SharedArrayBuffer` and spawns a second internal proxy worker. + +--- + +## 2. SQLDelight 2.3.2 Kotlin/WASM Driver + +### Official `app.cash.sqldelight` Artifacts + +| Artifact | Latest on Maven Central | Notes | +|---|---|---| +| `web-worker-driver` | 2.1.0 | For Kotlin/JS browser targets | +| `web-worker-driver-wasm-js` | 2.1.0 | For Kotlin/WASM browser targets | +| `web-worker-driver-js` | 2.1.0 | JS-only variant | + +**CRITICAL FINDING**: As of research date (May 2026), `app.cash.sqldelight:web-worker-driver-wasm-js` exists on Maven Central but its **latest release is 2.1.0, not 2.3.2**. SteleKit's project uses SQLDelight 2.3.2 throughout for JVM/Android. There is a **version mismatch**: the official wasm-js web-worker-driver tops out at 2.1.0. + +There is also a community fork `me.gulya.sqldelight:web-worker-driver-wasm-js:2.1.0-wasm` that targets WASM. + +### What the web-worker-driver Does + +- Communicates with a SQLite implementation running inside a Web Worker via message passing. +- The driver is **asynchronous**: `execute()`, `executeQuery()`, `newTransaction()`, `endTransaction()` all return `QueryResult` (not a plain value). +- Requires setting `generateAsync = true` in the SQLDelight Gradle config. +- The `awaitAsList()` / `awaitAsOne()` suspend extension functions replace synchronous equivalents. +- SQLDelight provides a `sqlite.worker.js` script that wraps sql.js — this is the default worker implementation. You can provide a custom worker that wraps `@sqlite.org/sqlite-wasm` + opfs-sahpool instead. + +### Alternative: Custom SqlDriver in Kotlin + +Since 2.3.2 has no matching `web-worker-driver-wasm-js`, the implementation team has three options: + +1. **Use `web-worker-driver-wasm-js:2.1.0`** — version mismatch with 2.3.2 runtime; may or may not be binary-compatible. Risk: high. +2. **Implement a custom `SqlDriver`** in Kotlin/WASM with JS interop calls to `@sqlite.org/sqlite-wasm` running in a dedicated JS Worker — all communication is async via Promises/callbacks. This is the most correct approach for 2.3.2. +3. **Vendor/copy the web-worker-driver source** and update it to work with 2.3.2. Labor-intensive but avoids version mismatch. + +**Prior art**: `dellisd/sqldelight-sqlite-wasm` (GitHub) experiments with exactly this: a SqlDriver for Kotlin/JS backed by `@sqlite.org/sqlite-wasm`. It targets Kotlin/JS (not WASM), but the JS interop patterns are applicable. + +--- + +## 3. Kotlin/WASM JS Interop Mechanisms + +### How to Call npm Packages from Kotlin/WASM + +Kotlin/WASM uses **ES modules only** (no CommonJS). The interop path is: + +1. **Declare `external` types and functions** with `@JsModule` annotation: + +```kotlin +@file:JsModule("@sqlite.org/sqlite-wasm") + +package dev.stapler.stelekit.db + +external fun sqlite3InitModule(config: JsAny): JsAny // returns Promise +``` + +2. **Call JS Promises** via `Promise.await()` (from `kotlinx.coroutines`): + +```kotlin +import kotlinx.coroutines.await + +val sqlite3: JsAny = sqlite3InitModule(config).unsafeCast>().await() +``` + +3. **Type restrictions**: Kotlin/WASM interop signatures must use `JsAny` or its subtypes at the boundaries. You cannot pass Kotlin data classes directly — you need to marshal to/from `JsAny`. + +4. **`@JsExport`**: marks Kotlin functions callable from JS side (used for the worker message handler). + +5. **`= js("...")` snippets**: small inline JS expressions can be embedded directly. + +### Key Difference vs Kotlin/JS + +- Kotlin/WASM uses `JsAny` as the universal JS value type; Kotlin/JS uses `dynamic`. +- `@JsNonModule` is NOT supported in WASM (ES modules only). +- `external` declarations must be at file/top-level or inside `external` classes. + +--- + +## 4. How npm Packages Are Bundled in Kotlin/WASM + +### Gradle Configuration + +```kotlin +// build.gradle.kts +val wasmJsMain by getting { + dependencies { + implementation(npm("@sqlite.org/sqlite-wasm", "3.46.1")) + } +} +``` + +The `npm()` Gradle function (not string `"npm:"` syntax) is the correct approach. The Kotlin Gradle plugin uses **webpack 5** to bundle everything, including npm dependencies, into the output JS bundle. + +### Worker Script Challenge + +The `@sqlite.org/sqlite-wasm` package includes a WASM binary (`sqlite3.wasm`) that must be served separately (not inlined). The webpack config needs to emit the `.wasm` file as a static asset, not inline it. This typically requires a webpack copy plugin or the package's own configuration. + +Alternatively, `@sqlite.org/sqlite-wasm` can be loaded from CDN with an integrity hash inside a dedicated worker script, avoiding the webpack bundling problem. + +--- + +## 5. Prior Art: "sqldelight wasm" and "kotlin wasm opfs sqlite" + +- **`dellisd/sqldelight-sqlite-wasm`** (GitHub): Kotlin/JS (not WASM) prototype using `@sqlite.org/sqlite-wasm` + SQLDelight's `web-worker-driver`. Worker file is in `src/jsMain/resources/sqlite.worker.js`. +- **`powersync.com` blog (November 2025)**: Comprehensive state-of-the-art review of all OPFS VFS options, confirms opfs-sahpool as the recommended choice for single-tab performance. +- **Kotlin Slack thread**: Developers report difficulty adding wasmJs to existing KMP projects with SQLDelight; the missing 2.3.x web-worker-driver is a known pain point. +- **No production Kotlin/WASM + OPFS + SQLDelight example found** in the public ecosystem as of May 2026. This is a greenfield integration. + +--- + +## Summary + +- Use `opfs-sahpool` VFS — no SAB required, 3–4× faster, single-tab sufficient. +- `app.cash.sqldelight:web-worker-driver-wasm-js` tops out at 2.1.0 — version mismatch with 2.3.2. +- Most likely path: implement a **custom async `SqlDriver`** in Kotlin/WASM using JS interop, with `@sqlite.org/sqlite-wasm` running in a dedicated JS Web Worker (required — OPFS sync API is blocked on main thread). +- npm dependency added via `implementation(npm("@sqlite.org/sqlite-wasm", "3.x.x"))` in `wasmJsMain` source set. +- Worker1/Promiser API is deprecated; use direct module API (`sqlite3InitModule` + `installOpfsSAHPoolVfs`). diff --git a/project_plans/stelekit-web-opfs/research/02-features.md b/project_plans/stelekit-web-opfs/research/02-features.md new file mode 100644 index 00000000..4bf2d346 --- /dev/null +++ b/project_plans/stelekit-web-opfs/research/02-features.md @@ -0,0 +1,315 @@ +# Agent 2 — Features Research: OPFS API, sqlite-wasm, and Playwright + +## 1. OPFS API Surface + +### Core APIs Needed + +The Origin Private File System (OPFS) API exposes two paths to file access: + +**Asynchronous (main thread + worker):** +```js +// Get the root directory handle +const root = await navigator.storage.getDirectory(); + +// Create/get a subdirectory +const dir = await root.getDirectoryHandle('stelekit', { create: true }); + +// Create/get a file handle +const fileHandle = await dir.getFileHandle('mydb.sqlite3', { create: true }); + +// Open a writable stream +const writable = await fileHandle.createWritable(); +await writable.write(buffer); +await writable.close(); +``` + +**Synchronous (Web Worker only — high performance):** +```js +// Must be in a Web Worker +const fileHandle = await dir.getFileHandle('mydb.sqlite3', { create: true }); +const syncHandle = await fileHandle.createSyncAccessHandle(); + +// Synchronous read/write — no await needed +syncHandle.write(buffer, { at: offset }); +syncHandle.read(readBuffer, { at: offset }); +syncHandle.flush(); +syncHandle.close(); +``` + +### Key Constraint +`createSyncAccessHandle()` is **only available inside Web Workers**. The main browser thread can only use the async API. This is why all SQLite-over-OPFS implementations run SQLite inside a worker. + +### Full Function Set Needed by the VFS + +For `opfs-sahpool` (OPFSSAHPool), the SQLite WASM runtime handles OPFS internally — you only need to call `installOpfsSAHPoolVfs()`. You do NOT need to call `navigator.storage.getDirectory()` manually; the VFS abstraction handles it. + +Required browser APIs (called internally by SQLite WASM): +- `navigator.storage.getDirectory()` — get OPFS root +- `FileSystemDirectoryHandle.getDirectoryHandle(name, {create})` — create subdirectory +- `FileSystemDirectoryHandle.getFileHandle(name, {create})` — open file +- `FileSystemFileHandle.createSyncAccessHandle()` — open sync access (worker-only) +- `FileSystemSyncAccessHandle.read()` / `.write()` / `.truncate()` / `.flush()` / `.close()` + +--- + +## 2. Which OPFS VFS Works in a Web Worker (for SteleKit) + +### The Two Main Choices + +**`opfs` (async proxy VFS):** +- Works inside a dedicated worker that SQLite spawns internally (the `sqlite3-opfs-async-proxy.js`). +- Requires `SharedArrayBuffer` to coordinate between the WASM sync I/O calls and the async OPFS API. +- When SAB is available (COOP/COEP headers set), this is reliable. +- `server.mjs` in SteleKit already sets COOP/COEP → SAB IS available in SteleKit's context. + +**`opfs-sahpool` (OPFSSAHPool VFS) — RECOMMENDED:** +- Runs entirely within a single worker. +- Uses `createSyncAccessHandle()` directly — no SAB needed. +- 3–4× faster I/O than the async proxy VFS. +- The VFS pre-allocates a pool of sync access handles (configurable via `initialCapacity`). +- Does NOT support multi-tab access to the same database file simultaneously. + +### Worker Architecture for opfs-sahpool + +``` +Main Thread (Kotlin/WASM) + │ + │ postMessage(sql, params) + ▼ +sqlite-worker.js (Web Worker) + │ + │ sqlite3InitModule() → installOpfsSAHPoolVfs() + │ → OpfsSAHPoolDb('/stelekit/graph-abc.sqlite3') + │ → db.exec(sql) + │ + │ postMessage(result) + ▼ +Main Thread (receives result, resolves Promise) +``` + +The `@sqlite.org/sqlite-wasm` package already handles threading internally for the `opfs` VFS. For `opfs-sahpool`, the developer controls the worker. + +--- + +## 3. Initialization Sequence for opfs-sahpool + +```js +// sqlite-worker.js (runs in a Web Worker) +import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; + +let db = null; +let poolUtil = null; + +async function init(dbPath) { + const sqlite3 = await sqlite3InitModule({ + print: (...args) => console.log(...args), + printErr: (...args) => console.error(...args), + }); + + // Install the SAHPool VFS into this worker's sqlite3 instance + poolUtil = await sqlite3.installOpfsSAHPoolVfs({ + name: 'opfs-sahpool', + directory: '/stelekit', // OPFS sub-directory + initialCapacity: 6, // number of pre-allocated sync access handles + clearOnInit: false, // do NOT wipe on restart — essential for persistence! + }); + + // Open (or create) the database + // Constructor throws if DB cannot be opened; wrap in try/catch for fallback. + db = new poolUtil.OpfsSAHPoolDb(dbPath); // e.g. '/stelekit/graph-default.sqlite3' + + self.postMessage({ type: 'ready' }); +} + +self.onmessage = async (e) => { + const { type, sql, bind, dbPath } = e.data; + if (type === 'init') { + await init(dbPath); + } else if (type === 'exec') { + const results = []; + db.exec({ sql, bind, rowMode: 'object', callback: (row) => results.push(row) }); + self.postMessage({ type: 'result', results }); + } +}; +``` + +Key initialization parameters: +- `clearOnInit: false` — do NOT clear on init (this is the persistence toggle). +- `directory` — OPFS subdirectory (isolated per origin, writable). +- `initialCapacity` — number of pre-allocated file handles (minimum: number of concurrent databases + 2). + +--- + +## 4. SharedArrayBuffer and OPFSSAHPool + +| VFS | SAB Required | Why | +|---|---|---| +| `opfs` | **Yes** | Proxy worker uses SAB+Atomics to convert async OPFS calls to synchronous SQLite VFS calls | +| `opfs-sahpool` | **No** | Uses `createSyncAccessHandle()` directly inside the worker — no async→sync bridging needed | +| `opfs-wl` | **Yes** | Write-lock variant of `opfs` | + +**For SteleKit**: since COOP/COEP headers are already set, both options work. But `opfs-sahpool` is preferred for performance and simpler architecture (no second proxy worker). + +**Atomics.waitAsync()**: As of 2025, this is available in all major browsers (required by the `opfs` VFS). The `opfs-sahpool` VFS does not use it. + +--- + +## 5. `@sqlite.org/sqlite-wasm` JS API Reference + +### Module Import + +```js +// In a worker (ES module worker) +import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; +// OR via CDN (with integrity hash for security): +import sqlite3InitModule from 'https://esm.run/@sqlite.org/sqlite-wasm@3.46.1-build2'; +``` + +### Initialization + +```js +const sqlite3 = await sqlite3InitModule({ print, printErr }); +// sqlite3.version.libVersion → '3.46.1' +// sqlite3.capi — low-level C API +// sqlite3.oo1 — high-level OO API (use this) +// sqlite3.installOpfsSAHPoolVfs — async function for SAH pool +``` + +### OO1 Database API (after VFS install) + +```js +// After installOpfsSAHPoolVfs(): +const db = new poolUtil.OpfsSAHPoolDb('/path/file.sqlite3'); + +// Execute SQL (synchronous, because we're in a worker with sync handles) +db.exec({ + sql: 'SELECT * FROM pages WHERE uuid = ?', + bind: [uuid], + rowMode: 'object', // returns {column: value} objects + callback: (row) => { /* process row */ } +}); + +// One-shot exec with result array: +const rows = db.exec('SELECT * FROM pages', { returnValue: 'resultRows', rowMode: 'object' }); + +// Prepared statement: +const stmt = db.prepare('INSERT INTO blocks VALUES (?, ?, ?)'); +stmt.bind([uuid, content, parentId]).stepReset().finalize(); + +// Transaction: +db.transaction(() => { + db.exec('INSERT INTO ...'); + db.exec('UPDATE ...'); +}); + +// Close: +db.close(); +``` + +### IMPORTANT: The Worker1/Promiser API is Deprecated (as of 2026-04-15) + +Do NOT use `sqlite3Worker1Promiser()`. The upstream SQLite project has deprecated this API as "too fragile, too imperformant, and too limited for non-toy software." Use direct module initialization instead. + +--- + +## 6. How to Test OPFS Persistence with Playwright + +### Key Challenge + +OPFS is an origin-scoped, persistent filesystem. Unlike `localStorage`, Playwright's standard `browser.newContext()` does NOT automatically preserve OPFS data between test runs unless using a persistent context or the same profile directory. + +### Approach 1: `page.reload()` Within a Single Test (Simplest for SteleKit) + +```typescript +test('OPFS persistence survives page reload', async ({ page }) => { + await page.goto('/'); + + // Wait for app to initialize + await page.waitForFunction(() => window.__stelekit_ready === true, { timeout: 30_000 }); + + // Write a note via UI interaction or JS evaluation + // (The actual mechanism depends on how SteleKit exposes state) + + // Reload the page — OPFS data survives within the same browser context + await page.reload(); + + // Wait for re-initialization + await page.waitForFunction(() => window.__stelekit_ready === true, { timeout: 30_000 }); + + // Assert the data is still present + // ... +}); +``` + +OPFS data **does** survive `page.reload()` within the same Playwright browser context (same origin, same profile). + +### Approach 2: Persistent Context (Cross-test persistence) + +```typescript +// playwright.config.ts +use: { + // Use a persistent context to preserve OPFS across test files + // Note: requires browser.launchPersistentContext() not browser.newContext() + userDataDir: '/tmp/playwright-stelekit-profile', +} +``` + +### Approach 3: Clear OPFS Between Tests (Isolation) + +```typescript +// In beforeEach: clear OPFS so tests don't bleed into each other +await page.evaluate(async () => { + const root = await navigator.storage.getDirectory(); + // Remove the stelekit directory + await root.removeEntry('stelekit', { recursive: true }); +}); +``` + +### Recommended Pattern for SteleKit's Persistence Test + +```typescript +test('SteleKit OPFS: data persists across page reload', async ({ page }) => { + // First load + await page.goto('/'); + await waitForWasmReady(page); // reuse existing helper + + // The app bootstraps an empty graph and shows today's journal — verify it loaded + // ... existing canvas paint assertions from demo.spec.ts ... + + // Trigger a data write: either via UI or by injecting JS that calls the worker + // (exact mechanism TBD based on how SteleKit exposes its graph state) + + // Reload — OPFS persists within same context + await page.reload(); + await waitForWasmReady(page); + + // Assert the same page/graph is present (not a fresh demo) + // Use page.evaluate() to inspect OPFS via navigator.storage.getDirectory() + // or verify via visual assertion / app state + const hasData = await page.evaluate(async () => { + try { + const root = await navigator.storage.getDirectory(); + const dir = await root.getDirectoryHandle('stelekit', { create: false }); + return true; // directory exists → data was written + } catch { + return false; // directory doesn't exist → OPFS write failed + } + }); + expect(hasData).toBe(true); +}); +``` + +### `clearOnInit` in Tests + +When writing Playwright tests that specifically verify fresh-start behavior, pass `clearOnInit: true` to `installOpfsSAHPoolVfs()` in a test-only build, or expose a JS function that clears OPFS before calling reload. + +--- + +## Summary + +- `opfs-sahpool` VFS is recommended — no SAB required, fastest performance, best for single-tab. +- OPFS requires a Web Worker — `createSyncAccessHandle()` is blocked on the main thread. +- Initialization: `sqlite3InitModule()` → `installOpfsSAHPoolVfs({ clearOnInit: false })` → `new poolUtil.OpfsSAHPoolDb(path)`. +- Worker1/Promiser API is **deprecated** — use direct module API. +- Playwright persistence tests: use `page.reload()` within one test (same browser context preserves OPFS); use `page.evaluate()` to verify OPFS directory existence. +- `clearOnInit: false` is essential — setting it to `true` would wipe all data on every load. diff --git a/project_plans/stelekit-web-opfs/research/03-architecture.md b/project_plans/stelekit-web-opfs/research/03-architecture.md new file mode 100644 index 00000000..a2e05db6 --- /dev/null +++ b/project_plans/stelekit-web-opfs/research/03-architecture.md @@ -0,0 +1,273 @@ +# Agent 3 — Architecture Research: SqlDriver, FileSystem, and Integration Design + +## 1. The `FileSystem` Interface — What OPFS Must Implement + +The common `FileSystem` interface (at `kmp/src/commonMain/.../platform/FileSystem.kt`) defines: + +```kotlin +interface FileSystem { + fun getDefaultGraphPath(): String + fun expandTilde(path: String): String + fun readFile(path: String): String? + fun writeFile(path: String, content: String): Boolean + fun listFiles(path: String): List + fun listDirectories(path: String): List + fun fileExists(path: String): Boolean + fun directoryExists(path: String): Boolean + fun createDirectory(path: String): Boolean + fun deleteFile(path: String): Boolean + fun pickDirectory(): String? + suspend fun pickDirectoryAsync(): String? = pickDirectory() + fun getLastModifiedTime(path: String): Long? + fun listFilesWithModTimes(path: String): List> + fun hasStoragePermission(): Boolean = true + fun getLibraryDisplayName(): String? = null + fun displayNameForPath(path: String): String + fun startExternalChangeDetection(scope: CoroutineScope, onChange: () -> Unit) {} + fun stopExternalChangeDetection() {} + fun renameFile(from: String, to: String): Boolean = false + fun getDownloadsPath(): String + suspend fun pickSaveFileAsync(suggestedName: String, mimeType: String): String? = null + fun updateShadow(path: String, content: String) {} + fun invalidateShadow(path: String) {} + suspend fun syncShadow(graphPath: String) {} +} +``` + +The current `PlatformFileSystem.kt` (wasmJsMain) is a stub — all methods return null/empty/false. + +### Methods Requiring OPFS Implementation + +For the OPFS-backed FileSystem (needed for `GraphLoader`/`GraphWriter` to work with markdown files): + +| Method | OPFS Implementation Strategy | +|---|---| +| `readFile(path)` | OPFS async: `getFileHandle` → `getFile()` → `text()` | +| `writeFile(path, content)` | OPFS async: `getFileHandle({create:true})` → `createWritable()` → write → close | +| `listFiles(path)` | OPFS async: `getDirectoryHandle` → `for await (entry of dir)` | +| `listDirectories(path)` | Same as listFiles, filter to directories | +| `fileExists(path)` | Try `getFileHandle`, catch `NotFoundError` | +| `directoryExists(path)` | Try `getDirectoryHandle`, catch `NotFoundError` | +| `createDirectory(path)` | `getDirectoryHandle(name, {create:true})` | +| `deleteFile(path)` | `dir.removeEntry(name)` | +| `getLastModifiedTime(path)` | `getFileHandle` → `getFile()` → `file.lastModified` | +| `getDefaultGraphPath()` | Return `/stelekit/graph` (OPFS path) | +| `pickDirectory()` | Return null (not supported in browser without File System Access API) | + +**CRITICAL CONSTRAINT**: All OPFS APIs are async (`Promise`-returning). The `FileSystem` interface uses synchronous signatures (`fun readFile(): String?`). This is an **impedance mismatch**. + +### Resolving the Async Impedance Mismatch + +Options: + +**Option A — Suspend functions in the FileSystem interface** (breaking change to all platforms): +Add `suspend` variants alongside sync ones. The interface already has `suspend fun pickDirectoryAsync()`. Pattern: add `suspend fun readFileAsync()` etc. Callers in `GraphLoader` use the suspend variant on browser, sync variant on JVM/Android. + +**Option B — All-in-memory pre-load** (OPFS → memory cache): +On browser startup, eagerly load all OPFS files into an in-memory map (the `DemoFileSystem` pattern). `GraphLoader` reads from memory; writes go to OPFS asynchronously in background. This matches how the existing `DemoFileSystem` works and avoids interface changes. + +**Option C — `runBlocking` equivalent** (NOT viable in WASM): +Kotlin/WASM is single-threaded. There is no `runBlocking` — blocking the coroutine dispatcher would deadlock the browser. + +**Option D — Worker-only file I/O**: +Run the entire FileSystem implementation inside the SQLite worker, so sync access handles can be used for file reads too. This couples SQLite and FileSystem in one worker, which is complex but avoids async issues. + +**Recommended: Option B** for Phase B implementation. Pre-load the OPFS file tree into a `MutableMap` at startup (in a `suspend fun init()` called before `GraphManager.addGraph()`), then use the in-memory cache for sync reads. Writes go to OPFS asynchronously. This is consistent with `DemoFileSystem` design and keeps the `FileSystem` interface unchanged. + +--- + +## 2. JVM `DriverFactory` — What a Real Implementation Looks Like + +From `kmp/src/jvmMain/.../db/DriverFactory.jvm.kt`: + +```kotlin +actual class DriverFactory actual constructor() { + actual fun createDriver(jdbcUrl: String): SqlDriver { + // 1. Ensure parent directory exists + // 2. Create connection pool with WAL + busy_timeout + val driver = PooledJdbcSqliteDriver(jdbcUrl, props, poolSize = 8) + // 3. Create schema (idempotent) + SteleDatabase.Schema.create(driver) + // 4. Run migrations + MigrationRunner.applyAll(driver) + return driver + } + actual fun getDatabaseUrl(graphId: String): String = "jdbc:sqlite:${dir}/stelekit-graph-$graphId.db" + actual fun getDatabaseDirectory(): String = jvmDatabaseDirectory() +} +``` + +The expected `SqlDriver` contract for SteleKit: +1. Created by `DriverFactory.createDriver(jdbcUrl)` — the `jdbcUrl` is purely a naming convention; in WASM the path is the OPFS path not a JDBC string. +2. Must support `SteleDatabase.Schema.create(driver)` — this calls `execute()` with `CREATE TABLE IF NOT EXISTS` DDL. +3. Must support `MigrationRunner.applyAll(driver)` — series of `execute()` calls with ALTER TABLE / INSERT. +4. Must support `SteleDatabaseQueries` — all the generated SELECT/INSERT/UPDATE/DELETE operations. + +--- + +## 3. SQLDelight `SqlDriver` Interface + +The `app.cash.sqldelight:runtime` interface (simplified): + +```kotlin +interface SqlDriver : Closeable { + fun execute( + identifier: Int?, + sql: String, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)? = null + ): QueryResult + + fun executeQuery( + identifier: Int?, + sql: String, + mapper: (SqlCursor) -> QueryResult, + parameters: Int, + binders: (SqlPreparedStatement.() -> Unit)? = null + ): QueryResult + + fun newTransaction(): QueryResult + fun endTransaction(successful: Boolean): QueryResult + fun currentTransaction(): Transacter.Transaction? + + fun addListener(vararg queryKeys: String, listener: Query.Listener) + fun removeListener(vararg queryKeys: String, listener: Query.Listener) + fun notifyListeners(vararg queryKeys: String) + + override fun close() +} +``` + +Key: `QueryResult` has two subtypes: +- `QueryResult.Value` — synchronous result +- `QueryResult.AsyncValue` — async result (returns a `suspend fun await(): T`) + +The web-worker-driver returns `AsyncValue` for all operations. For a custom Kotlin/WASM driver talking to an OPFS-backed SQLite worker, all operations must also return `AsyncValue` (wrapping Promise-based message-passing to the worker). + +### Schema.create() Compatibility + +`SteleDatabase.Schema` is generated by SQLDelight. If the Gradle config has `generateAsync = false` (current state), `Schema.create()` expects synchronous `SqlDriver.execute()` (returning `QueryResult.Value`). If `generateAsync = true`, it expects `AsyncValue`. + +**CRITICAL**: The current SteleKit build does not set `generateAsync = true` in `sqldelight {}`. Changing this would regenerate ALL query code to use suspend functions, affecting JVM and Android too (they'd need async-capable drivers or shims). This is a significant change. + +**Recommended approach**: Keep `generateAsync = false`. Implement the WASM `SqlDriver` to execute SQL **synchronously** from the Kotlin perspective by using a **synchronous wrapper** approach: + +Since `opfs-sahpool` in a worker provides `db.exec()` synchronously (the worker itself is async from the main thread's perspective, but internally sync), the architecture becomes: + +``` +Kotlin/WASM main (coroutine suspend) ←→ JS Worker (sync SQLite via opfs-sahpool) +``` + +The Kotlin side uses `Promise.await()` to suspend the coroutine until the worker responds. The `SqlDriver` returns `QueryResult.Value(...)` after awaiting the worker response. The SQLDelight-generated code does not need to change. + +--- + +## 4. Should the WASM SQLite Driver Live Entirely in Kotlin or Use a JS Adapter? + +### Option A: Full Kotlin/WASM `SqlDriver` with JS Interop + +```kotlin +class OpfsSqlDriver(private val worker: JsAny) : SqlDriver { + override fun execute(identifier: Int?, sql: String, ...): QueryResult { + // This is the key problem: SqlDriver.execute() is NOT suspend + // We cannot call Promise.await() from a non-suspend function + // → This approach requires QueryResult.AsyncValue + } +} +``` + +Problem: `SqlDriver.execute()` is synchronous by interface contract (returns `QueryResult` not `suspend`). Returning `AsyncValue` requires `generateAsync = true` in SQLDelight config, which changes generated code project-wide. + +### Option B: JS Adapter Pattern (Recommended for Phase B) + +Use a **JS-side SQLite wrapper** that handles the async-to-sync conversion internally, exposing a **synchronous-looking interface** to Kotlin: + +Since `opfs-sahpool` + worker provides a synchronous SQLite interface from within the worker, and Kotlin can suspend while waiting for the worker response, the implementation looks like: + +```kotlin +// In wasmJsMain — Kotlin/WASM driver +class WasmOpfsSqlDriver : SqlDriver { + private var initialized = false + + // Called once, before creating the driver, in a suspend context + suspend fun initialize(graphPath: String) { + // postMessage to worker: { type: "open", path: graphPath } + // await worker response via Promise + initialized = true + } + + override fun execute(identifier: Int?, sql: String, parameters: Int, binders: ...): QueryResult { + // Cannot suspend here — must use runBlocking equivalent + // On WASM there is no runBlocking → returns AsyncValue + return QueryResult.AsyncValue { + // This suspend lambda runs in a coroutine + sendToWorkerAndAwait("exec", sql, ...) + } + } +} +``` + +This requires `generateAsync = true`. See above. + +### Option C: Pre-load + In-memory SQLite (Hybrid, avoids async SqlDriver entirely) + +1. On startup, open the OPFS file, read all bytes into a Kotlin `ByteArray`. +2. Pass that byte array to an **in-memory SQLite instance** (using a Kotlin/WASM WASM SQLite, e.g., sql.js equivalent). +3. All SQL operations run synchronously against the in-memory instance. +4. On writes: serialize the SQLite file back to OPFS asynchronously. + +This avoids the async SqlDriver problem entirely. The existing `IN_MEMORY` backend continues to work synchronously; OPFS is just the persistence layer for loading and saving the full DB file. + +**This is viable** and conceptually simpler, but has a write-durability concern (crash between in-memory write and OPFS flush). + +--- + +## 5. Cleanest Architecture Recommendation + +Given the constraints (no `generateAsync`, single-threaded WASM, no runBlocking): + +### Recommended: Dedicated SQLite Worker + AsyncValue SqlDriver + generateAsync=true (scoped to wasmJs) + +SQLDelight supports per-target async generation. The `generateAsync` flag applies globally, but you can use `expect`/`actual` to provide different `SqlDriver` implementations per platform without changing generated code if you use a shim. + +**Alternative clean path**: Use a **blocking JS interop trick** specific to wasmJs: + +In Kotlin/WASM, you can call JS synchronous functions. If the SQLite worker is replaced with a **synchronous JS wrapper** that internally uses `Atomics.wait()` to block until the worker responds, then Kotlin can call it synchronously. However, `Atomics.wait()` is blocked on the main thread in browsers (it would freeze the UI). + +**Final recommendation**: The cleanest architecture for Phase B is: + +1. Implement a dedicated JS worker (`sqlite-stelekit-worker.js`) using `opfs-sahpool`. +2. Expose a JS module with async functions (`openDb`, `execSql`, `execQuery`, `beginTransaction`, `commit`, `rollback`). +3. From Kotlin/WASM, use `external` declarations + `Promise.await()` to call these async functions. +4. Set `generateAsync = true` in the SQLDelight gradle config — this is the correct long-term direction per SQLDelight maintainers for browser targets. +5. Use `awaitAsList()` / `awaitAsOne()` in repository code on the wasmJs platform — or use `expect`/`actual` to keep synchronous calls on JVM/Android. + +The risk: `generateAsync = true` changes ALL generated query code to suspend functions. The JVM sqlite-driver and Android driver both support `QueryResult.Value` (synchronous), which is compatible with async drivers when called from a coroutine scope. This change is additive and safe. + +--- + +## 6. Database Directory Structure in `commonMain/db/` + +Key files: +- `DriverFactory.kt` — `expect class DriverFactory()` with `init(context)`, `createDriver(jdbcUrl)`, `getDatabaseUrl(graphId)`, `getDatabaseDirectory()` +- `GraphManager.kt` — calls `DriverFactory.createDriver(getDatabaseUrl(graphId))` for each graph +- `MigrationRunner.kt` — applies SQL migrations; called in `DriverFactory.jvm.kt` after schema creation +- `DatabaseWriteActor.kt` — serializes all writes via a single coroutine actor +- `RestrictedDatabaseQueries.kt` — write-gating layer (`@DirectSqlWrite`) + +The wasmJs `DriverFactory.js.kt` needs to: +1. Accept `graphId` via `getDatabaseUrl(graphId)` (currently returns a fake JDBC URL). +2. Return an actual `SqlDriver` from `createDriver()`. +3. The returned `SqlDriver` must work with `SteleDatabase.Schema.create()` and `MigrationRunner.applyAll()`. + +For the OPFS path: the "URL" is just an OPFS path like `/stelekit/graph-.sqlite3`. The JDBC URL prefix is meaningless in WASM — strip it or ignore it. + +--- + +## Summary + +- `PlatformFileSystem.kt` in wasmJsMain needs an OPFS-backed implementation with async pre-loading pattern to bridge the sync `FileSystem` interface. +- The cleanest `SqlDriver` path requires `generateAsync = true` in SQLDelight gradle config, enabling async queries project-wide (safe for JVM/Android, correct for WASM). +- The `DriverFactory.js.kt` should strip the JDBC prefix and use the remainder as an OPFS path. +- MigrationRunner must work with the async driver — verify it only calls `execute()` not synchronous result patterns. +- Architecture: Kotlin/WASM → JS external declarations → JS async functions → Web Worker → opfs-sahpool VFS → OPFS filesystem. diff --git a/project_plans/stelekit-web-opfs/research/04-pitfalls.md b/project_plans/stelekit-web-opfs/research/04-pitfalls.md new file mode 100644 index 00000000..7dd2da96 --- /dev/null +++ b/project_plans/stelekit-web-opfs/research/04-pitfalls.md @@ -0,0 +1,251 @@ +# Agent 4 — Pitfalls Research: OPFS + SQLite + Kotlin/WASM + +## 1. Known Pitfalls of OPFS + SQLite in the Browser + +### Pitfall 1: OPFS is Worker-Only for High-Performance Access + +`FileSystemSyncAccessHandle` (the fast, synchronous OPFS API) is **only available inside Web Workers**. Calling `createSyncAccessHandle()` from the main browser thread throws a `DOMException`. This means: + +- SQLite's OPFS VFS (`opfs` or `opfs-sahpool`) **must run in a Web Worker**. +- All SQL operations from the main thread must go through message-passing to the worker. +- This adds latency per query (serialization + postMessage + deserialization). + +**Impact for SteleKit**: The `DriverFactory.js.kt` cannot directly instantiate SQLite and call it synchronously from the Kotlin/WASM main thread. A dedicated JS worker is required as an intermediary. + +### Pitfall 2: OPFS Async API Is Available on Main Thread But Is Slow + +The async OPFS API (`getFile()`, `createWritable()`) IS available on the main thread, but these are Promise-based and significantly slower than sync access handles. Using the async API for a SQLite VFS would require custom VFS implementation and is not what `@sqlite.org/sqlite-wasm` supports out of the box. + +### Pitfall 3: Exclusive Lock with opfs-sahpool + +The `opfs-sahpool` VFS holds **exclusive access** to all pre-allocated file handles for the duration of its installation. This means: + +- Only ONE tab can use `opfs-sahpool` against the same OPFS directory at a time. +- Opening a second tab attempting to use the same VFS configuration will fail or behave unexpectedly. +- For SteleKit this is acceptable (single-tab assumption per the architecture), but must be documented. +- The `opfs` VFS supports multi-tab concurrency but requires SAB. + +### Pitfall 4: OPFS File Paths Are Not Real Filesystem Paths + +OPFS paths like `/stelekit/graph-abc.sqlite3` are **logical paths within the VFS**, not actual filesystem paths. Users cannot browse to them in Finder/Explorer. The browser exposes OPFS files only via the API (or via DevTools → Application → Storage → Origin Private File System in Chrome 116+). + +### Pitfall 5: Quota Limits + +- OPFS is subject to browser storage quotas (typically 60% of available disk space for the origin). +- If quota is exceeded, `createSyncAccessHandle()` or write operations throw `QuotaExceededError`. +- For SteleKit (a note-taking app with text data), hitting the quota would require hundreds of MB of notes — low risk but should be handled gracefully. + +### Pitfall 6: Worker1/Promiser Deprecation + +The `sqlite3Worker1Promiser` API from `@sqlite.org/sqlite-wasm` was **deprecated on 2026-04-15**. Many online examples still use it. Use `sqlite3InitModule` + direct module API instead. + +--- + +## 2. Does Kotlin/WASM Support Web Workers? + +### Current State (May 2026) + +**Short answer: Not directly from Kotlin/WASM code.** + +Kotlin/WASM runs entirely on the main browser thread. There is currently no native Kotlin API to spawn a `new Worker(...)` from Kotlin/WASM code directly. The threading situation: + +- `Dispatchers.Default` and `Dispatchers.IO` on wasmJs both run on the **main thread** (backed by `setTimeout`/event loop). +- There is no `Dispatchers.IO` offload to a thread pool — all coroutines run cooperatively on the single main thread event loop. +- True multithreading in WASM requires the WASM threads proposal (SharedArrayBuffer + `wasm-threads`), which is still experimental and not enabled by default in Kotlin/WASM. + +### How to Spawn a Web Worker from Kotlin/WASM + +Use JS interop to call `new Worker()` from the JS side: + +```kotlin +// Kotlin/WASM external declaration +external fun createWorker(scriptPath: String): JsAny + +// JS snippet +fun createWorker(path: String): JsAny = js("new Worker(path, { type: 'module' })") +``` + +Alternatively, place the worker creation in a JS helper module imported via `@JsModule`. + +### Worker Communication from Kotlin/WASM + +```kotlin +external fun postMessageToWorker(worker: JsAny, message: JsAny): Unit +// js("worker.postMessage(message)") + +// Receiving: set worker.onmessage callback +external fun setWorkerOnMessage(worker: JsAny, callback: JsAny): Unit +// js("worker.onmessage = callback") +``` + +Since Kotlin/WASM cannot `Atomics.wait()` on the main thread (would freeze the browser), all worker communication is inherently async via Promise + `await()` in Kotlin coroutines. + +### The Async VFS and WASM's Single Thread + +The `opfs` (async proxy) VFS works differently: SQLite WASM internally spawns a **second proxy worker** via `new Worker('./sqlite3-opfs-async-proxy.js')`. This second worker uses `FileSystemSyncAccessHandle` synchronously, then communicates results back via SharedArrayBuffer + Atomics to the primary worker where SQLite runs. Since Kotlin/WASM runs on the main thread (not in a worker), using the `opfs` VFS directly from Kotlin/WASM's perspective means the SQLite module itself would need to be in a worker — which is exactly the architecture recommended. + +**The recommended architecture**: Kotlin/WASM (main thread) ↔ (postMessage/Promise) ↔ SQLite Worker (JS, contains `@sqlite.org/sqlite-wasm` + opfs-sahpool). The Kotlin code never runs inside the worker. + +--- + +## 3. Private Browsing / Incognito Mode Behavior + +### Browser-Specific Behavior + +| Browser | OPFS in Private Browsing | +|---|---| +| **Chrome/Chromium** | OPFS is available but storage quota is severely limited (~100 MB, non-persistent) | +| **Firefox** | OPFS is **completely disabled** in private browsing mode | +| **Safari** | OPFS is **completely disabled** in private browsing / incognito mode | + +### Implication for the Fallback Strategy + +The requirements spec states: "If OPFS is unavailable (e.g., private browsing with restricted storage), fall back to IN_MEMORY with a console warning." + +This fallback is **feasible** and is the correct approach. The detection pattern: + +```js +// In the SQLite worker, during initialization: +async function tryOpenOpfs(dbPath) { + try { + const root = await navigator.storage.getDirectory(); + // If this throws, OPFS is unavailable + return true; + } catch (e) { + return false; + } +} + +// Or attempt installOpfsSAHPoolVfs and catch: +try { + poolUtil = await sqlite3.installOpfsSAHPoolVfs({ ... }); + db = new poolUtil.OpfsSAHPoolDb(dbPath); + postMessage({ type: 'ready', backend: 'opfs-sahpool' }); +} catch (e) { + // Fall back to in-memory + db = new sqlite3.oo1.DB(':memory:'); + postMessage({ type: 'ready', backend: 'memory', warning: 'OPFS unavailable: ' + e.message }); +} +``` + +On the Kotlin side, the `DriverFactory` reads the `backend` field from the worker's ready message and configures `GraphBackend` accordingly. + +### NOTE: `IN_MEMORY` SqlDriver Fallback + +If the whole SQLite worker approach fails, the simplest fallback is to NOT use the worker at all and return to `GraphBackend.IN_MEMORY` (the current behavior). The `DriverFactory.createDriver()` could throw `UnsupportedOperationException` for the `IN_MEMORY` case and let `GraphManager` handle it — which is what the current code already does. + +--- + +## 4. Known Issues with `@sqlite.org/sqlite-wasm` and Kotlin/WASM Interop + +### Memory Model Differences + +- Kotlin/WASM uses WasmGC (garbage-collected WASM) — its memory is managed by the GC, not a linear memory buffer. +- `@sqlite.org/sqlite-wasm` runs in standard WASM linear memory (Emscripten-compiled). +- These two WASM modules **cannot share memory directly**. They run in separate WASM instances. +- **This is actually fine** for the worker pattern: Kotlin/WASM is on the main thread; SQLite WASM is in a worker. They communicate via serialized messages (`postMessage`), not shared memory. +- No pointer-passing between Kotlin/WASM and SQLite WASM is needed or safe. + +### Pointer Passing: Not Applicable + +Since the two WASM modules are in separate contexts (main thread vs worker), there is no pointer-passing issue. All data exchange is via `postMessage` with serializable values (strings, numbers, arrays). + +### JsAny Type Constraints + +Kotlin/WASM interop requires all JS-boundary types to be `JsAny` or primitives. The result of `postMessage` responses are received as `JsAny` and must be cast manually. SQLite query results (rows as objects) must be serialized in the worker and deserialized in Kotlin using `JsAny` accessors. + +### ES Module Requirement + +Kotlin/WASM supports ES modules only. `@sqlite.org/sqlite-wasm` supports ES module import. The worker script must also use `type: 'module'`: + +```js +new Worker('./sqlite-worker.js', { type: 'module' }) +``` + +Standard workers (non-module) cannot use ES module `import`. This is a compatibility concern: older browsers that support WASM but not module workers would fail. However, all browsers that support OPFS (Chrome 86+, Firefox 111+, Safari 16.4+) also support module workers — this is not a practical concern. + +### Webpack / Bundle Issues + +- `@sqlite.org/sqlite-wasm` ships with its own `sqlite3.wasm` binary. Webpack must copy this file to the output directory as a static asset, not inline it. +- The package uses a dynamic `new URL('./sqlite3.wasm', import.meta.url)` pattern to locate the WASM binary relative to the JS bundle. This pattern requires webpack 5's asset modules to work correctly. +- If served from a CDN, the WASM binary is fetched separately — this bypasses the webpack issue but adds a network dependency. +- **Recommendation**: Use the CDN approach for Phase B simplicity, with a pinned version and integrity hash. Switch to bundled for production. + +--- + +## 5. Browser Support Cutoffs for OPFS Async VFS + +### OPFS Async VFS (`opfs` — requires SAB + Atomics) + +| Browser | Minimum Version | Notes | +|---|---|---| +| Chrome | 86+ (OPFS) / 113+ (stable SAB everywhere) | Full support since Chrome 113 | +| Firefox | 111+ | OPFS support added; SAB requires COOP/COEP | +| Safari | 17+ | SAB available since Safari 15.2 with COOP/COEP; full OPFS since Safari 16.4; the `opfs` VFS (sub-worker bug) fixed in Safari 17 | + +### OPFS SAHPool VFS (`opfs-sahpool` — NO SAB required) + +| Browser | Minimum Version | Notes | +|---|---|---| +| Chrome | 109+ | `createSyncAccessHandle` API; stable since Chrome 109 | +| Firefox | 111+ | `createSyncAccessHandle` in workers | +| Safari | 16.4+ | Works including Safari 16.4 (no need for Safari 17+) | + +**SteleKit targets Chrome 119+, Firefox 120+, Safari 18.2+** (per `index.html`). All targets support `opfs-sahpool` without issues. + +**`Atomics.waitAsync()`** (needed by `opfs` VFS): Available Chrome 87+, Firefox 116+, Safari 16.4+. Since `opfs-sahpool` doesn't use it, this is moot for the recommended VFS. + +--- + +## 6. COOP/COEP Headers and OPFS Interaction + +### Current State in SteleKit + +`server.mjs` already sets: +``` +Cross-Origin-Opener-Policy: same-origin +Cross-Origin-Embedder-Policy: require-corp +Cross-Origin-Resource-Policy: same-origin +``` + +These headers: +1. Enable `SharedArrayBuffer` — available for `opfs` VFS if needed. +2. Enable `crossOriginIsolated = true` — required for SAB. +3. Do NOT directly affect OPFS availability — OPFS works with or without these headers. + +### Consequence for opfs-sahpool + +Since `opfs-sahpool` doesn't require SAB, the COOP/COEP headers are **not needed for OPFS itself**. They're already set for the `coi-serviceworker.min.js` (which ensures SAB for the existing WASM build). Keeping them doesn't break anything. + +### CDN Resources and COEP + +If `@sqlite.org/sqlite-wasm` WASM binary is loaded from a CDN, the CDN response must include: +``` +Cross-Origin-Resource-Policy: cross-origin +``` +OR the Kotlin WASM app uses `credentialless` for COEP instead of `require-corp`. + +**Risk**: Loading from CDN without proper CORP headers will fail under `require-corp` COEP. Fix: either bundle the WASM binary locally or use a CDN that sets CORP headers (unpkg.com does; esm.run/jsDelivr may not — verify at implementation time). + +--- + +## 7. Licensing + +- `@sqlite.org/sqlite-wasm` is based on SQLite itself. +- SQLite is **public domain** — no copyright, no license, no restrictions. +- The `@sqlite.org/sqlite-wasm` npm package is published by the SQLite project under the same public domain dedication. +- **No licensing issues** for use in SteleKit (Elastic License 2.0 for SteleKit code). + +--- + +## Summary + +- OPFS sync access is **worker-only** — architecture must use a Web Worker for SQLite. This is the #1 pitfall. +- Kotlin/WASM cannot spawn Web Workers via native Kotlin API — use `js("new Worker(...)")` interop. +- All coroutines on Kotlin/WASM run on the main thread — no Dispatchers.IO thread pool. +- Private browsing: Firefox and Safari completely disable OPFS; Chrome limits to ~100MB. Fallback to IN_MEMORY is feasible and required. +- No memory model conflicts between Kotlin/WASM (WasmGC) and SQLite WASM (linear memory) because they run in separate threads/contexts with message-passing. +- `opfs-sahpool` works on Safari 16.4+ (better than `opfs`'s Safari 17+ requirement). +- COOP/COEP headers already set in SteleKit — SAB available. But `opfs-sahpool` doesn't need it. +- CDN delivery of `sqlite3.wasm` requires CORP header from CDN, or bundle locally. +- SQLite is public domain — no licensing concern. From ce0ffc18f74dc59d7d24b016b8aaef383430cae2 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Thu, 7 May 2026 13:37:38 -0700 Subject: [PATCH 3/8] fix(db): propagate suspend through async SQLDelight call chain and fix Detekt violations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RestrictedDatabaseQueries: fix missing spaces in method names (fun→suspend fun propagation dropped the space character), add suspend modifier to all write methods to match generateAsync=true API (returns Long not QueryResult) - Propagate suspend up the call chain: OperationLogger, ChangelogRepository, DebugFlagRepository, HistogramRetentionJob, SqlDelightPageRepository.upsertPage, DatabaseWriteActor.logSaveBlocks — all callers are already in coroutine contexts - PooledJdbcSqliteDriverTest: wrap transaction call in runBlocking for test context - SearchSkeleton: add modifier param to SearchSkeletonList (ModifierMissing) - SearchDialog: extract Regex constants, fix SearchResultRow param order, add modifier to ActivePrefixChipRow, extract complex condition to named booleans - SearchViewModel: rethrow CancellationException before generic catch Co-Authored-By: Claude Sonnet 4.6 --- .../stapler/stelekit/db/DatabaseWriteActor.kt | 2 +- .../stapler/stelekit/db/OperationLogger.kt | 16 +- .../stelekit/db/RestrictedDatabaseQueries.kt | 153 +++++++++--------- .../stelekit/migration/ChangelogRepository.kt | 10 +- .../performance/DebugFlagRepository.kt | 4 +- .../performance/HistogramRetentionJob.kt | 2 +- .../repository/SqlDelightPageRepository.kt | 2 +- .../stelekit/ui/components/SearchDialog.kt | 24 ++- .../stelekit/ui/components/SearchSkeleton.kt | 4 +- .../stelekit/ui/screens/SearchViewModel.kt | 2 + .../stelekit/db/PooledJdbcSqliteDriverTest.kt | 34 ++-- 11 files changed, 132 insertions(+), 121 deletions(-) diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/DatabaseWriteActor.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/DatabaseWriteActor.kt index 44b125b1..b9b024c5 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/DatabaseWriteActor.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/DatabaseWriteActor.kt @@ -315,7 +315,7 @@ class DatabaseWriteActor( * Log INSERT or UPDATE operations for each block in the batch. * Blocks not present in [existingByUuid] are treated as inserts. */ - private fun logSaveBlocks(blocks: List, existingByUuid: Map) { + private suspend fun logSaveBlocks(blocks: List, existingByUuid: Map) { val logger = opLogger ?: return for (block in blocks) { val existing = existingByUuid[block.uuid] diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/OperationLogger.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/OperationLogger.kt index 29a4cf21..dcd8abd7 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/OperationLogger.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/OperationLogger.kt @@ -55,7 +55,7 @@ class OperationLogger( private var seq: Long = -1L @OptIn(DirectSqlWrite::class) - private fun nextSeq(): Long { + private suspend fun nextSeq(): Long { if (seq < 0) { seq = db.steleDatabaseQueries.selectLogicalClock(sessionId) .executeAsOneOrNull() ?: 0L @@ -65,42 +65,42 @@ class OperationLogger( return seq } - fun logInsert(block: Block) = log( + suspend fun logInsert(block: Block) = log( opType = OpType.INSERT_BLOCK, entityUuid = block.uuid, pageUuid = block.pageUuid, payload = OpPayload(after = block.toSnapshot()), ) - fun logUpdate(before: Block, after: Block) = log( + suspend fun logUpdate(before: Block, after: Block) = log( opType = OpType.UPDATE_BLOCK, entityUuid = after.uuid, pageUuid = after.pageUuid, payload = OpPayload(before = before.toSnapshot(), after = after.toSnapshot()), ) - fun logDelete(block: Block) = log( + suspend fun logDelete(block: Block) = log( opType = OpType.DELETE_BLOCK, entityUuid = block.uuid, pageUuid = block.pageUuid, payload = OpPayload(before = block.toSnapshot()), ) - fun logSyncBarrier() = log( + suspend fun logSyncBarrier() = log( opType = OpType.SYNC_BARRIER, entityUuid = null, pageUuid = null, payload = OpPayload(), ) - fun logBatchStart(batchId: String) = log( + suspend fun logBatchStart(batchId: String) = log( opType = OpType.BATCH_START, entityUuid = null, pageUuid = null, payload = OpPayload(batchId = batchId), ) - fun logBatchEnd(batchId: String) = log( + suspend fun logBatchEnd(batchId: String) = log( opType = OpType.BATCH_END, entityUuid = null, pageUuid = null, @@ -108,7 +108,7 @@ class OperationLogger( ) @OptIn(DirectSqlWrite::class) - private fun log(opType: OpType, entityUuid: String?, pageUuid: String?, payload: OpPayload) { + private suspend fun log(opType: OpType, entityUuid: String?, pageUuid: String?, payload: OpPayload) { try { val opId = UuidGenerator.generateV7() val payloadJson = json.encodeToString(payload) diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/RestrictedDatabaseQueries.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/RestrictedDatabaseQueries.kt index 33d8238a..88945a86 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/RestrictedDatabaseQueries.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/RestrictedDatabaseQueries.kt @@ -1,7 +1,6 @@ package dev.stapler.stelekit.db -import app.cash.sqldelight.TransactionWithoutReturn -import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.SuspendingTransactionWithoutReturn /** * Wraps [SteleDatabaseQueries] and gates every mutating method behind [DirectSqlWrite]. @@ -23,13 +22,13 @@ class RestrictedDatabaseQueries(private val queries: SteleDatabaseQueries) { // ── Transactions ────────────────────────────────────────────────────────── @DirectSqlWrite - fun transaction(noEnclosing: Boolean = false, body: TransactionWithoutReturn.() -> Unit) = + suspend fun transaction(noEnclosing: Boolean = false, body: suspend SuspendingTransactionWithoutReturn.() -> Unit) = queries.transaction(noEnclosing, body) // ── Block writes ────────────────────────────────────────────────────────── @DirectSqlWrite - fun insertBlock( + suspend fun insertBlock( uuid: String, page_uuid: String, parent_uuid: String?, @@ -43,76 +42,76 @@ class RestrictedDatabaseQueries(private val queries: SteleDatabaseQueries) { version: Long, content_hash: String?, block_type: String, - ): QueryResult = queries.insertBlock( + ): Long = queries.insertBlock( uuid, page_uuid, parent_uuid, left_uuid, content, level, position, created_at, updated_at, properties, version, content_hash, block_type, ) @DirectSqlWrite - fun updateBlockParent(parent_uuid: String?, uuid: String): QueryResult = + suspend fun updateBlockParent(parent_uuid: String?, uuid: String): Long = queries.updateBlockParent(parent_uuid, uuid) @DirectSqlWrite - fun updateBlockParentPositionAndLevel( + suspend fun updateBlockParentPositionAndLevel( parent_uuid: String?, position: Long, level: Long, uuid: String, - ): QueryResult = queries.updateBlockParentPositionAndLevel(parent_uuid, position, level, uuid) + ): Long = queries.updateBlockParentPositionAndLevel(parent_uuid, position, level, uuid) @DirectSqlWrite - fun updateBlockHierarchy( + suspend fun updateBlockHierarchy( parent_uuid: String?, left_uuid: String?, position: Long, level: Long, uuid: String, - ): QueryResult = queries.updateBlockHierarchy(parent_uuid, left_uuid, position, level, uuid) + ): Long = queries.updateBlockHierarchy(parent_uuid, left_uuid, position, level, uuid) @DirectSqlWrite - fun updateBlockPositionOnly(position: Long, uuid: String): QueryResult = + suspend fun updateBlockPositionOnly(position: Long, uuid: String): Long = queries.updateBlockPositionOnly(position, uuid) @DirectSqlWrite - fun updateBlockContent(content: String, updated_at: Long, content_hash: String?, uuid: String): QueryResult = + suspend fun updateBlockContent(content: String, updated_at: Long, content_hash: String?, uuid: String): Long = queries.updateBlockContent(content, updated_at, content_hash, uuid) @DirectSqlWrite - fun updateBlockLevelOnly(level: Long, uuid: String): QueryResult = + suspend fun updateBlockLevelOnly(level: Long, uuid: String): Long = queries.updateBlockLevelOnly(level, uuid) @DirectSqlWrite - fun updateBlockLeftUuid(left_uuid: String?, uuid: String): QueryResult = + suspend fun updateBlockLeftUuid(left_uuid: String?, uuid: String): Long = queries.updateBlockLeftUuid(left_uuid, uuid) @DirectSqlWrite - fun updateBlockProperties(properties: String?, uuid: String): QueryResult = + suspend fun updateBlockProperties(properties: String?, uuid: String): Long = queries.updateBlockProperties(properties, uuid) @DirectSqlWrite - fun deleteBlockByUuid(uuid: String): QueryResult = + suspend fun deleteBlockByUuid(uuid: String): Long = queries.deleteBlockByUuid(uuid) @DirectSqlWrite - fun deleteBlockChildren(parent_uuid: String?): QueryResult = + suspend fun deleteBlockChildren(parent_uuid: String?): Long = queries.deleteBlockChildren(parent_uuid) @DirectSqlWrite - fun deleteBlocksByPageUuid(page_uuid: String): QueryResult = + suspend fun deleteBlocksByPageUuid(page_uuid: String): Long = queries.deleteBlocksByPageUuid(page_uuid) @DirectSqlWrite - fun deleteBlocksByPageUuids(page_uuids: Collection): QueryResult = + suspend fun deleteBlocksByPageUuids(page_uuids: Collection): Long = queries.deleteBlocksByPageUuids(page_uuids) @DirectSqlWrite - fun deleteAllBlocks(): QueryResult = + suspend fun deleteAllBlocks(): Long = queries.deleteAllBlocks() // ── Page writes ─────────────────────────────────────────────────────────── @DirectSqlWrite - fun insertPage( + suspend fun insertPage( uuid: String, name: String, namespace: String?, @@ -125,13 +124,13 @@ class RestrictedDatabaseQueries(private val queries: SteleDatabaseQueries) { is_journal: Long?, journal_date: String?, is_content_loaded: Long, - ): QueryResult = queries.insertPage( + ): Long = queries.insertPage( uuid, name, namespace, file_path, created_at, updated_at, properties, version, is_favorite, is_journal, journal_date, is_content_loaded, ) @DirectSqlWrite - fun updatePage( + suspend fun updatePage( namespace: String?, file_path: String?, updated_at: Long, @@ -142,45 +141,45 @@ class RestrictedDatabaseQueries(private val queries: SteleDatabaseQueries) { journal_date: String?, is_content_loaded: Long, uuid: String, - ): QueryResult = queries.updatePage( + ): Long = queries.updatePage( namespace, file_path, updated_at, properties, version, is_favorite, is_journal, journal_date, is_content_loaded, uuid, ) @DirectSqlWrite - fun updatePageName(name: String, uuid: String): QueryResult = + suspend fun updatePageName(name: String, uuid: String): Long = queries.updatePageName(name, uuid) @DirectSqlWrite - fun updatePageProperties(properties: String?, uuid: String): QueryResult = + suspend fun updatePageProperties(properties: String?, uuid: String): Long = queries.updatePageProperties(properties, uuid) @DirectSqlWrite - fun updatePageFavorite(is_favorite: Long?, uuid: String): QueryResult = + suspend fun updatePageFavorite(is_favorite: Long?, uuid: String): Long = queries.updatePageFavorite(is_favorite, uuid) @DirectSqlWrite - fun deletePageByUuid(uuid: String): QueryResult = + suspend fun deletePageByUuid(uuid: String): Long = queries.deletePageByUuid(uuid) @DirectSqlWrite - fun deleteAllPages(): QueryResult = + suspend fun deleteAllPages(): Long = queries.deleteAllPages() // ── Reference writes ────────────────────────────────────────────────────── @DirectSqlWrite - fun insertBlockReference(from_block_uuid: String, to_block_uuid: String, created_at: Long): QueryResult = + suspend fun insertBlockReference(from_block_uuid: String, to_block_uuid: String, created_at: Long): Long = queries.insertBlockReference(from_block_uuid, to_block_uuid, created_at) @DirectSqlWrite - fun deleteBlockReference(from_block_uuid: String, to_block_uuid: String): QueryResult = + suspend fun deleteBlockReference(from_block_uuid: String, to_block_uuid: String): Long = queries.deleteBlockReference(from_block_uuid, to_block_uuid) // ── Plugin data writes ──────────────────────────────────────────────────── @DirectSqlWrite - fun insertPluginData( + suspend fun insertPluginData( plugin_id: String, entity_type: String, entity_uuid: String, @@ -188,20 +187,20 @@ class RestrictedDatabaseQueries(private val queries: SteleDatabaseQueries) { value_: String, created_at: Long, updated_at: Long?, - ): QueryResult = queries.insertPluginData(plugin_id, entity_type, entity_uuid, key, value_, created_at, updated_at) + ): Long = queries.insertPluginData(plugin_id, entity_type, entity_uuid, key, value_, created_at, updated_at) @DirectSqlWrite - fun updatePluginData( + suspend fun updatePluginData( value_: String, updated_at: Long?, plugin_id: String, entity_type: String, entity_uuid: String, key: String, - ): QueryResult = queries.updatePluginData(value_, updated_at, plugin_id, entity_type, entity_uuid, key) + ): Long = queries.updatePluginData(value_, updated_at, plugin_id, entity_type, entity_uuid, key) @DirectSqlWrite - fun upsertPluginData( + suspend fun upsertPluginData( plugin_id: String, entity_type: String, entity_uuid: String, @@ -209,50 +208,50 @@ class RestrictedDatabaseQueries(private val queries: SteleDatabaseQueries) { value_: String, created_at: Long, updated_at: Long?, - ): QueryResult = queries.upsertPluginData(plugin_id, entity_type, entity_uuid, key, value_, created_at, updated_at) + ): Long = queries.upsertPluginData(plugin_id, entity_type, entity_uuid, key, value_, created_at, updated_at) @DirectSqlWrite - fun deletePluginData(plugin_id: String, entity_type: String, entity_uuid: String, key: String): QueryResult = + suspend fun deletePluginData(plugin_id: String, entity_type: String, entity_uuid: String, key: String): Long = queries.deletePluginData(plugin_id, entity_type, entity_uuid, key) @DirectSqlWrite - fun deletePluginDataByPlugin(plugin_id: String): QueryResult = + suspend fun deletePluginDataByPlugin(plugin_id: String): Long = queries.deletePluginDataByPlugin(plugin_id) @DirectSqlWrite - fun deletePluginDataByEntity(entity_type: String, entity_uuid: String): QueryResult = + suspend fun deletePluginDataByEntity(entity_type: String, entity_uuid: String): Long = queries.deletePluginDataByEntity(entity_type, entity_uuid) // ── Histogram writes ────────────────────────────────────────────────────── @DirectSqlWrite - fun insertHistogramBucketIfAbsent(operation_name: String, bucket_ms: Long, recorded_at: Long): QueryResult = + suspend fun insertHistogramBucketIfAbsent(operation_name: String, bucket_ms: Long, recorded_at: Long): Long = queries.insertHistogramBucketIfAbsent(operation_name, bucket_ms, recorded_at) @DirectSqlWrite - fun incrementHistogramBucketCount(recorded_at: Long, operation_name: String, bucket_ms: Long): QueryResult = + suspend fun incrementHistogramBucketCount(recorded_at: Long, operation_name: String, bucket_ms: Long): Long = queries.incrementHistogramBucketCount(recorded_at, operation_name, bucket_ms) @DirectSqlWrite - fun deleteOldHistogramRows(recorded_at: Long): QueryResult = + suspend fun deleteOldHistogramRows(recorded_at: Long): Long = queries.deleteOldHistogramRows(recorded_at) // ── Debug flag writes ───────────────────────────────────────────────────── @DirectSqlWrite - fun upsertDebugFlag(key: String, value_: Long, updated_at: Long): QueryResult = + suspend fun upsertDebugFlag(key: String, value_: Long, updated_at: Long): Long = queries.upsertDebugFlag(key, value_, updated_at) // ── Metadata writes ─────────────────────────────────────────────────────── @DirectSqlWrite - fun upsertMetadata(key: String, value_: String): QueryResult = + suspend fun upsertMetadata(key: String, value_: String): Long = queries.upsertMetadata(key, value_) // ── Operation log writes ────────────────────────────────────────────────── @DirectSqlWrite - fun insertOperation( + suspend fun insertOperation( op_id: String, session_id: String, seq: Long, @@ -261,20 +260,20 @@ class RestrictedDatabaseQueries(private val queries: SteleDatabaseQueries) { page_uuid: String?, payload: String, created_at: Long, - ): QueryResult = queries.insertOperation(op_id, session_id, seq, op_type, entity_uuid, page_uuid, payload, created_at) + ): Long = queries.insertOperation(op_id, session_id, seq, op_type, entity_uuid, page_uuid, payload, created_at) @DirectSqlWrite - fun upsertLogicalClock(session_id: String, seq: Long): QueryResult = + suspend fun upsertLogicalClock(session_id: String, seq: Long): Long = queries.upsertLogicalClock(session_id, seq) @DirectSqlWrite - fun deleteOperationsBefore(session_id: String, seq: Long): QueryResult = + suspend fun deleteOperationsBefore(session_id: String, seq: Long): Long = queries.deleteOperationsBefore(session_id, seq) // ── Migration changelog writes ──────────────────────────────────────────── @DirectSqlWrite - fun insertMigrationRecord( + suspend fun insertMigrationRecord( id: String, graph_id: String, description: String, @@ -286,33 +285,33 @@ class RestrictedDatabaseQueries(private val queries: SteleDatabaseQueries) { execution_order: Long, changes_applied: Long, error_message: String?, - ): QueryResult = queries.insertMigrationRecord( + ): Long = queries.insertMigrationRecord( id, graph_id, description, checksum, applied_at, execution_ms, status, applied_by, execution_order, changes_applied, error_message, ) @DirectSqlWrite - fun updateMigrationStatus( + suspend fun updateMigrationStatus( status: String, error_message: String?, execution_ms: Long, changes_applied: Long, id: String, graph_id: String, - ): QueryResult = queries.updateMigrationStatus(status, error_message, execution_ms, changes_applied, id, graph_id) + ): Long = queries.updateMigrationStatus(status, error_message, execution_ms, changes_applied, id, graph_id) @DirectSqlWrite - fun deleteMigrationRecord(id: String, graph_id: String): QueryResult = + suspend fun deleteMigrationRecord(id: String, graph_id: String): Long = queries.deleteMigrationRecord(id, graph_id) @DirectSqlWrite - fun updateMigrationChecksum(checksum: String, id: String, graph_id: String): QueryResult = + suspend fun updateMigrationChecksum(checksum: String, id: String, graph_id: String): Long = queries.updateMigrationChecksum(checksum, id, graph_id) // ── Span writes ─────────────────────────────────────────────────────────── @DirectSqlWrite - fun insertSpan( + suspend fun insertSpan( trace_id: String, span_id: String, parent_span_id: String, @@ -322,92 +321,92 @@ class RestrictedDatabaseQueries(private val queries: SteleDatabaseQueries) { duration_ms: Long, attributes_json: String, status_code: String, - ): QueryResult = queries.insertSpan(trace_id, span_id, parent_span_id, name, start_epoch_ms, end_epoch_ms, duration_ms, attributes_json, status_code) + ): Long = queries.insertSpan(trace_id, span_id, parent_span_id, name, start_epoch_ms, end_epoch_ms, duration_ms, attributes_json, status_code) @DirectSqlWrite - fun deleteSpansOlderThan(end_epoch_ms: Long): QueryResult = + suspend fun deleteSpansOlderThan(end_epoch_ms: Long): Long = queries.deleteSpansOlderThan(end_epoch_ms) @DirectSqlWrite - fun deleteExcessSpans(limit: Long): QueryResult = + suspend fun deleteExcessSpans(limit: Long): Long = queries.deleteExcessSpans(limit) @DirectSqlWrite - fun deleteAllSpans(): QueryResult = + suspend fun deleteAllSpans(): Long = queries.deleteAllSpans() // ── UUID migration writes ───────────────────────────────────────────────── @DirectSqlWrite - fun updateBlockUuidForMigration(uuid: String, uuid_: String): QueryResult = + suspend fun updateBlockUuidForMigration(uuid: String, uuid_: String): Long = queries.updateBlockUuidForMigration(uuid, uuid_) @DirectSqlWrite - fun updateParentUuidForMigration(parent_uuid: String?, parent_uuid_: String?): QueryResult = + suspend fun updateParentUuidForMigration(parent_uuid: String?, parent_uuid_: String?): Long = queries.updateParentUuidForMigration(parent_uuid, parent_uuid_) @DirectSqlWrite - fun updateLeftUuidForMigration(left_uuid: String?, left_uuid_: String?): QueryResult = + suspend fun updateLeftUuidForMigration(left_uuid: String?, left_uuid_: String?): Long = queries.updateLeftUuidForMigration(left_uuid, left_uuid_) @DirectSqlWrite - fun updateBlockReferencesFromForMigration(from_block_uuid: String, from_block_uuid_: String): QueryResult = + suspend fun updateBlockReferencesFromForMigration(from_block_uuid: String, from_block_uuid_: String): Long = queries.updateBlockReferencesFromForMigration(from_block_uuid, from_block_uuid_) @DirectSqlWrite - fun updateBlockReferencesToForMigration(to_block_uuid: String, to_block_uuid_: String): QueryResult = + suspend fun updateBlockReferencesToForMigration(to_block_uuid: String, to_block_uuid_: String): Long = queries.updateBlockReferencesToForMigration(to_block_uuid, to_block_uuid_) @DirectSqlWrite - fun updatePropertiesBlockUuidForMigration(block_uuid: String, block_uuid_: String): QueryResult = + suspend fun updatePropertiesBlockUuidForMigration(block_uuid: String, block_uuid_: String): Long = queries.updatePropertiesBlockUuidForMigration(block_uuid, block_uuid_) // ── Query stats writes ──────────────────────────────────────────────────── @DirectSqlWrite - fun insertQueryStatIfAbsent(app_version: String, table_name: String, operation: String, first_seen: Long, last_seen: Long): QueryResult = + suspend fun insertQueryStatIfAbsent(app_version: String, table_name: String, operation: String, first_seen: Long, last_seen: Long): Long = queries.insertQueryStatIfAbsent(app_version, table_name, operation, first_seen, last_seen) @DirectSqlWrite - fun mergeQueryStat( + suspend fun mergeQueryStat( calls: Long, errors: Long, total_ms: Long, min_ms: Long, max_ms: Long, b1: Long, b5: Long, b16: Long, b50: Long, b100: Long, b500: Long, b_inf: Long, last_seen: Long, app_version: String, table_name: String, operation: String, - ): QueryResult = + ): Long = queries.mergeQueryStat(calls, errors, total_ms, min_ms, max_ms, b1, b5, b16, b50, b100, b500, b_inf, last_seen, app_version, table_name, operation) @DirectSqlWrite - fun deleteQueryStatsForVersion(app_version: String): QueryResult = + suspend fun deleteQueryStatsForVersion(app_version: String): Long = queries.deleteQueryStatsForVersion(app_version) // ── Visit tracking ──────────────────────────────────────────────────────── @DirectSqlWrite - fun insertPageVisitIfAbsent(page_uuid: String, last_visited_at: Long): QueryResult = + suspend fun insertPageVisitIfAbsent(page_uuid: String, last_visited_at: Long): Long = queries.insertPageVisitIfAbsent(page_uuid, last_visited_at) @DirectSqlWrite - fun updatePageVisit(last_visited_at: Long, page_uuid: String): QueryResult = + suspend fun updatePageVisit(last_visited_at: Long, page_uuid: String): Long = queries.updatePageVisit(last_visited_at, page_uuid) // ── Maintenance ─────────────────────────────────────────────────────────── @DirectSqlWrite - fun pragmaWalCheckpointTruncate() = queries.pragmaWalCheckpointTruncate() + suspend fun pragmaWalCheckpointTruncate() = queries.pragmaWalCheckpointTruncate() @DirectSqlWrite - fun recomputeAllBacklinkCounts(): QueryResult = queries.recomputeAllBacklinkCounts() + suspend fun recomputeAllBacklinkCounts(): Long = queries.recomputeAllBacklinkCounts() @DirectSqlWrite - fun recomputeBacklinkCountForPage(name: String): QueryResult = + suspend fun recomputeBacklinkCountForPage(name: String): Long = queries.recomputeBacklinkCountForPage(name) // ── Git config writes ───────────────────────────────────────────────────── @DirectSqlWrite - fun insertOrReplaceGitConfig( + suspend fun insertOrReplaceGitConfig( graph_id: String, repo_root: String, wiki_subdir: String, @@ -427,5 +426,5 @@ class RestrictedDatabaseQueries(private val queries: SteleDatabaseQueries) { ) @DirectSqlWrite - fun deleteGitConfig(graph_id: String) = queries.deleteGitConfig(graph_id) + suspend fun deleteGitConfig(graph_id: String) = queries.deleteGitConfig(graph_id) } diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/migration/ChangelogRepository.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/migration/ChangelogRepository.kt index 04139b4d..1fc8d195 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/migration/ChangelogRepository.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/migration/ChangelogRepository.kt @@ -25,7 +25,7 @@ class ChangelogRepository(private val db: SteleDatabase) { .associate { it.id to it.checksum } } - fun markRunning(id: String, graphId: String, order: Int, checksum: String, description: String) { + suspend fun markRunning(id: String, graphId: String, order: Int, checksum: String, description: String) { restricted.insertMigrationRecord( id = id, graph_id = graphId, @@ -41,7 +41,7 @@ class ChangelogRepository(private val db: SteleDatabase) { ) } - fun markApplied(id: String, graphId: String, executionMs: Long, changesApplied: Int) { + suspend fun markApplied(id: String, graphId: String, executionMs: Long, changesApplied: Int) { restricted.updateMigrationStatus( status = MigrationStatus.APPLIED.name, error_message = null, @@ -52,7 +52,7 @@ class ChangelogRepository(private val db: SteleDatabase) { ) } - fun markFailed(id: String, graphId: String, errorMessage: String) { + suspend fun markFailed(id: String, graphId: String, errorMessage: String) { restricted.updateMigrationStatus( status = MigrationStatus.FAILED.name, error_message = errorMessage, @@ -70,7 +70,7 @@ class ChangelogRepository(private val db: SteleDatabase) { .map { it.id } } - fun deleteRecord(id: String, graphId: String) { + suspend fun deleteRecord(id: String, graphId: String) { restricted.deleteMigrationRecord(id, graphId) } @@ -89,7 +89,7 @@ class ChangelogRepository(private val db: SteleDatabase) { * Used when a migration's `checksumBody` has been legitimately changed (e.g. a comment edit). * Calling this voids the tamper-detection guarantee for the updated migration — use with care. */ - fun updateChecksum(id: String, graphId: String, newChecksum: String) { + suspend fun updateChecksum(id: String, graphId: String, newChecksum: String) { restricted.updateMigrationChecksum( checksum = newChecksum, id = id, diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/performance/DebugFlagRepository.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/performance/DebugFlagRepository.kt index 22655426..6c82ee95 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/performance/DebugFlagRepository.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/performance/DebugFlagRepository.kt @@ -12,7 +12,7 @@ class DebugFlagRepository(private val database: SteleDatabase) { private val restricted = RestrictedDatabaseQueries(database.steleDatabaseQueries) @OptIn(DirectSqlWrite::class) - fun setFlag(key: String, enabled: Boolean) { + suspend fun setFlag(key: String, enabled: Boolean) { restricted.upsertDebugFlag( key = key, value_ = if (enabled) 1L else 0L, @@ -33,7 +33,7 @@ class DebugFlagRepository(private val database: SteleDatabase) { isDebugMenuVisible = false // never persisted as visible ) - fun saveDebugMenuState(state: DebugMenuState) { + suspend fun saveDebugMenuState(state: DebugMenuState) { setFlag("frame_overlay", state.isFrameOverlayEnabled) setFlag("otel_stdout", state.isOtelStdoutEnabled) setFlag("jank_stats", state.isJankStatsEnabled) diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/performance/HistogramRetentionJob.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/performance/HistogramRetentionJob.kt index acd7933e..6234f37c 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/performance/HistogramRetentionJob.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/performance/HistogramRetentionJob.kt @@ -39,7 +39,7 @@ class HistogramRetentionJob( } @OptIn(DirectSqlWrite::class) - private fun deleteOldRows(cutoff: Long) { + private suspend fun deleteOldRows(cutoff: Long) { restricted.deleteOldHistogramRows(cutoff) } } diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/SqlDelightPageRepository.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/SqlDelightPageRepository.kt index c5415e93..e9d0f625 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/SqlDelightPageRepository.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/repository/SqlDelightPageRepository.kt @@ -163,7 +163,7 @@ class SqlDelightPageRepository( } } - private fun upsertPage(page: Page) { + private suspend fun upsertPage(page: Page) { queries.insertPage( uuid = page.uuid, name = page.name, diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchDialog.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchDialog.kt index 6276b51f..75fb55ea 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchDialog.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchDialog.kt @@ -40,6 +40,10 @@ import dev.stapler.stelekit.ui.screens.SearchViewModel import dev.stapler.stelekit.util.toTitleCase import kotlinx.coroutines.flow.Flow +private val TAG_REGEX = Regex("""#\S+""") +private val SCOPE_REGEX = Regex("""/(pages?|blocks?|journal|current)\b""", RegexOption.IGNORE_CASE) +private val DATE_REGEX = Regex("""modified:(today|day|week|month|year)""", RegexOption.IGNORE_CASE) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchDialog( @@ -330,7 +334,10 @@ fun SearchDialog( } } } - if (uiState.results.isEmpty() && uiState.query.isNotEmpty() && !uiState.isLoading && !uiState.isSkeletonVisible) { + val noResults = uiState.results.isEmpty() + val hasQuery = uiState.query.isNotEmpty() + val isIdle = !uiState.isLoading && !uiState.isSkeletonVisible + if (noResults && hasQuery && isIdle) { Box( modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center @@ -424,17 +431,17 @@ fun SearchDialog( parsedQuery = uiState.parsedQuery, onRemoveTag = { viewModel.onQueryChange( - uiState.query.replace(Regex("""#\S+"""), "").trim() + uiState.query.replace(TAG_REGEX, "").trim() ) }, onRemoveScope = { viewModel.onQueryChange( - uiState.query.replace(Regex("""/(pages?|blocks?|journal|current)\b""", RegexOption.IGNORE_CASE), "").trim() + uiState.query.replace(SCOPE_REGEX, "").trim() ) }, onRemoveDate = { viewModel.onQueryChange( - uiState.query.replace(Regex("""modified:(today|day|week|month|year)""", RegexOption.IGNORE_CASE), "").trim() + uiState.query.replace(DATE_REGEX, "").trim() ) }, onRemoveProperty = { key -> @@ -479,12 +486,12 @@ fun SearchDialog( @Composable fun SearchResultRow( title: String, + isSelected: Boolean, + onClick: () -> Unit, subtitle: String? = null, relativeDate: String? = null, inlineTags: List = emptyList(), snippet: String? = null, - isSelected: Boolean, - onClick: () -> Unit ) { Row( modifier = Modifier @@ -574,7 +581,8 @@ fun ActivePrefixChipRow( onRemoveTag: () -> Unit, onRemoveScope: () -> Unit, onRemoveDate: () -> Unit, - onRemoveProperty: (String) -> Unit + onRemoveProperty: (String) -> Unit, + modifier: Modifier = Modifier, ) { if (parsedQuery == null) return val hasAnyFilter = parsedQuery.tagFilter != null @@ -584,7 +592,7 @@ fun ActivePrefixChipRow( if (!hasAnyFilter) return Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp) diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchSkeleton.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchSkeleton.kt index c2b21841..90ba5004 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchSkeleton.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchSkeleton.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.unit.dp private val widthFractions = listOf(0.55f, 0.75f, 0.40f, 0.65f, 0.80f, 0.45f) @Composable -fun SearchSkeletonList(rowCount: Int = 6) { +fun SearchSkeletonList(rowCount: Int = 6, modifier: Modifier = Modifier) { val infiniteTransition = rememberInfiniteTransition(label = "skeleton") val animatedAlpha by infiniteTransition.animateFloat( initialValue = 0.3f, @@ -44,7 +44,7 @@ fun SearchSkeletonList(rowCount: Int = 6) { val color = MaterialTheme.colorScheme.onSurface.copy(alpha = animatedAlpha * 0.15f) - Column { + Column(modifier = modifier) { repeat(rowCount) { i -> val titleFraction = widthFractions[i % widthFractions.size] Row( diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/SearchViewModel.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/SearchViewModel.kt index e6f1046c..af7c1a09 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/SearchViewModel.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/SearchViewModel.kt @@ -150,6 +150,8 @@ class SearchViewModel( } _uiState.update { it.copy(recentPages = recentPageItems) } } + } catch (e: CancellationException) { + throw e } catch (_: Exception) { // Ignore errors loading recent pages — non-critical } diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/db/PooledJdbcSqliteDriverTest.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/db/PooledJdbcSqliteDriverTest.kt index af167b13..7db0a9ea 100644 --- a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/db/PooledJdbcSqliteDriverTest.kt +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/db/PooledJdbcSqliteDriverTest.kt @@ -274,22 +274,24 @@ class PooledJdbcSqliteDriverTest { val now = System.currentTimeMillis() // Write a page via the real SteleDatabase schema - db.steleDatabaseQueries.run { - transaction { - insertPage( - uuid = "test-page-uuid", - name = "Test Page", - namespace = null, - file_path = null, - created_at = now, - updated_at = now, - properties = null, - version = 1L, - is_favorite = 0, - is_journal = 0, - journal_date = null, - is_content_loaded = 0, - ) + runBlocking { + db.steleDatabaseQueries.run { + transaction { + insertPage( + uuid = "test-page-uuid", + name = "Test Page", + namespace = null, + file_path = null, + created_at = now, + updated_at = now, + properties = null, + version = 1L, + is_favorite = 0, + is_journal = 0, + journal_date = null, + is_content_loaded = 0, + ) + } } } From 229a50eaa0ebf95948e2ff55f31ec2ab11250c54 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Thu, 7 May 2026 13:48:48 -0700 Subject: [PATCH 4/8] fix(android): adapt async SQLDelight schema for AndroidSqliteDriver and fix param order With generateAsync=true, SteleDatabase.Schema is SqlSchema> but AndroidSqliteDriver requires SqlSchema>. Add async-extensions dependency and use .synchronous() adapter to bridge the type mismatch. Also fix SearchSkeletonList ComposableParamOrder: modifier must precede other optional params. Co-Authored-By: Claude Sonnet 4.6 --- kmp/build.gradle.kts | 1 + .../kotlin/dev/stapler/stelekit/db/DriverFactory.android.kt | 3 ++- .../dev/stapler/stelekit/ui/components/SearchSkeleton.kt | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/kmp/build.gradle.kts b/kmp/build.gradle.kts index 01452693..588c6fa4 100644 --- a/kmp/build.gradle.kts +++ b/kmp/build.gradle.kts @@ -65,6 +65,7 @@ kotlin { // SQLDelight implementation("app.cash.sqldelight:runtime:2.3.2") implementation("app.cash.sqldelight:coroutines-extensions:2.3.2") + implementation("app.cash.sqldelight:async-extensions:2.3.2") // Compose Multiplatform implementation("org.jetbrains.compose.runtime:runtime:1.7.3") diff --git a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/db/DriverFactory.android.kt b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/db/DriverFactory.android.kt index 4a258209..a965697c 100644 --- a/kmp/src/androidMain/kotlin/dev/stapler/stelekit/db/DriverFactory.android.kt +++ b/kmp/src/androidMain/kotlin/dev/stapler/stelekit/db/DriverFactory.android.kt @@ -1,6 +1,7 @@ package dev.stapler.stelekit.db import android.content.Context +import app.cash.sqldelight.async.coroutines.synchronous import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory @@ -38,7 +39,7 @@ actual class DriverFactory actual constructor() { // AndroidSqliteDriver handles schema creation (fresh installs) and numbered .sqm // migrations (via SQLiteOpenHelper.onUpgrade) automatically. val driver = AndroidSqliteDriver( - schema = SteleDatabase.Schema, + schema = SteleDatabase.Schema.synchronous(), context = context, name = dbName, factory = RequerySQLiteOpenHelperFactory() diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchSkeleton.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchSkeleton.kt index 90ba5004..3c2ce1a7 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchSkeleton.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/components/SearchSkeleton.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.unit.dp private val widthFractions = listOf(0.55f, 0.75f, 0.40f, 0.65f, 0.80f, 0.45f) @Composable -fun SearchSkeletonList(rowCount: Int = 6, modifier: Modifier = Modifier) { +fun SearchSkeletonList(modifier: Modifier = Modifier, rowCount: Int = 6) { val infiniteTransition = rememberInfiniteTransition(label = "skeleton") val animatedAlpha by infiniteTransition.animateFloat( initialValue = 0.3f, From 0698b6ac5a1fd3c22faf4489b16fe98b9d36348b Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Thu, 7 May 2026 14:08:35 -0700 Subject: [PATCH 5/8] fix(web): resolve Wasm/JS compile errors in OPFS driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove invalid `override` from WasmOpfsSqlDriver.endTransaction — SqlDriver interface has no endTransaction method in SQLDelight 2.3.2 - Extract js() calls in OpfsInterop suspend functions to private non-suspend helpers (Kotlin/Wasm restriction: js() must be a single top-level expression) - Extract js() call in Main.kt launch block to top-level markSteleKitReady() for the same reason Co-Authored-By: Claude Sonnet 4.6 --- .../kotlin/dev/stapler/stelekit/browser/Main.kt | 4 +++- .../stapler/stelekit/db/WasmOpfsSqlDriver.kt | 2 +- .../stapler/stelekit/platform/OpfsInterop.kt | 17 ++++++++++++----- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/browser/Main.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/browser/Main.kt index 9abaf9ff..a5c15202 100644 --- a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/browser/Main.kt +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/browser/Main.kt @@ -15,6 +15,8 @@ import dev.stapler.stelekit.ui.StelekitApp import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch +private fun markSteleKitReady(): Unit = js("window.__stelekit_ready = true") + @OptIn(ExperimentalComposeUiApi::class) fun main() { val scope = MainScope() @@ -41,7 +43,7 @@ fun main() { defaultBackend = backend, ) - js("window.__stelekit_ready = true") + markSteleKitReady() CanvasBasedWindow(canvasElementId = "ComposeTarget") { StelekitApp( diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/WasmOpfsSqlDriver.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/WasmOpfsSqlDriver.kt index fdade4e5..29c52cba 100644 --- a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/WasmOpfsSqlDriver.kt +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/WasmOpfsSqlDriver.kt @@ -80,7 +80,7 @@ class WasmOpfsSqlDriver(private val workerScriptPath: String) : SqlDriver { } } - override fun endTransaction(successful: Boolean): QueryResult = QueryResult.AsyncValue { + fun endTransaction(successful: Boolean): QueryResult = QueryResult.AsyncValue { val id = nextMsgId() val promise = createWorkerResponsePromise(worker, id) workerPostMessage(worker, buildTransactionEndMessage(id, successful)) diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/OpfsInterop.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/OpfsInterop.kt index 458bd2f0..7c907c34 100644 --- a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/OpfsInterop.kt +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/OpfsInterop.kt @@ -2,14 +2,19 @@ package dev.stapler.stelekit.platform import kotlinx.coroutines.await -internal suspend fun getOpfsRoot(): JsAny = - (js("navigator.storage.getDirectory()") as kotlin.js.Promise).await() +private fun opfsRootPromise(): kotlin.js.Promise = js("navigator.storage.getDirectory()") +private fun directoryHandlePromise(parent: JsAny, name: String, create: Boolean): kotlin.js.Promise = + js("parent.getDirectoryHandle(name, { create: create })") +private fun fileHandlePromise(parent: JsAny, name: String, create: Boolean): kotlin.js.Promise = + js("parent.getFileHandle(name, { create: create })") + +internal suspend fun getOpfsRoot(): JsAny = opfsRootPromise().await() internal suspend fun getDirectoryHandle(parent: JsAny, name: String, create: Boolean): JsAny = - (js("parent.getDirectoryHandle(name, { create: create })") as kotlin.js.Promise).await() + directoryHandlePromise(parent, name, create).await() internal suspend fun getFileHandle(parent: JsAny, name: String, create: Boolean): JsAny = - (js("parent.getFileHandle(name, { create: create })") as kotlin.js.Promise).await() + fileHandlePromise(parent, name, create).await() private fun iteratorValues(handle: JsAny): JsAny = js("handle.values()") private fun iteratorNext(iter: JsAny): kotlin.js.Promise = js("iter.next()") @@ -75,5 +80,7 @@ internal suspend fun opfsDeleteFile(path: String) { } val fileName = parts.last() dirRemoveEntry(dir, fileName).await() - } catch (_: Throwable) {} + } catch (e: Throwable) { + println("[SteleKit] OPFS delete failed for $path: ${e.message}") + } } From c8afdda0d0d2d2c2f5d1a51bdaf3c6b0b713f2a0 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Thu, 7 May 2026 14:31:48 -0700 Subject: [PATCH 6/8] fix(web): add explicit JsAny type annotations to await() calls in Wasm Kotlin/Wasm cannot infer T for Promise.await() when there is no expected type in context (variable without annotation, discarded result). Add explicit `: JsAny` type on stored results and @Suppress UNUSED_VARIABLE for discarded awaits. Co-Authored-By: Claude Sonnet 4.6 --- .../dev/stapler/stelekit/db/WasmOpfsSqlDriver.kt | 10 +++++----- .../dev/stapler/stelekit/platform/OpfsInterop.kt | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/WasmOpfsSqlDriver.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/WasmOpfsSqlDriver.kt index 29c52cba..9f09349b 100644 --- a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/WasmOpfsSqlDriver.kt +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/WasmOpfsSqlDriver.kt @@ -19,7 +19,7 @@ class WasmOpfsSqlDriver(private val workerScriptPath: String) : SqlDriver { suspend fun init(dbPath: String) { val readyPromise = createWorkerReadyPromise(worker) workerPostMessage(worker, buildInitMessage(dbPath)) - val readyMsg = readyPromise.await() + val readyMsg: JsAny = readyPromise.await() actualBackend = getMessageBackend(readyMsg) val warning = getMessageWarning(readyMsg) if (warning != null) { @@ -43,7 +43,7 @@ class WasmOpfsSqlDriver(private val workerScriptPath: String) : SqlDriver { } else emptyJsArray() val promise = createWorkerResponsePromise(worker, id) workerPostMessage(worker, buildExecuteLongMessage(id, sql, bindArr)) - val resp = promise.await() + val resp: JsAny = promise.await() getMessageChanges(resp) } @@ -62,7 +62,7 @@ class WasmOpfsSqlDriver(private val workerScriptPath: String) : SqlDriver { } else emptyJsArray() val promise = createWorkerResponsePromise(worker, id) workerPostMessage(worker, buildQueryMessage(id, sql, bindArr)) - val resp = promise.await() + val resp: JsAny = promise.await() val rows = getMessageRows(resp) val cursor = JsRowCursor(rows) mapper(cursor).await() @@ -72,7 +72,7 @@ class WasmOpfsSqlDriver(private val workerScriptPath: String) : SqlDriver { val id = nextMsgId() val promise = createWorkerResponsePromise(worker, id) workerPostMessage(worker, buildTransactionBeginMessage(id)) - promise.await() + @Suppress("UNUSED_VARIABLE") val _begin: JsAny = promise.await() object : Transacter.Transaction() { override val enclosingTransaction: Transacter.Transaction? = null override fun endTransaction(successful: Boolean): QueryResult = @@ -84,7 +84,7 @@ class WasmOpfsSqlDriver(private val workerScriptPath: String) : SqlDriver { val id = nextMsgId() val promise = createWorkerResponsePromise(worker, id) workerPostMessage(worker, buildTransactionEndMessage(id, successful)) - promise.await() + @Suppress("UNUSED_VARIABLE") val _end: JsAny = promise.await() Unit } diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/OpfsInterop.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/OpfsInterop.kt index 7c907c34..44a86b1d 100644 --- a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/OpfsInterop.kt +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/OpfsInterop.kt @@ -25,7 +25,7 @@ internal suspend fun listOpfsEntries(dirHandle: JsAny): List { val entries = mutableListOf() val iterator = iteratorValues(dirHandle) while (true) { - val next = iteratorNext(iterator).await() + val next: JsAny = iteratorNext(iterator).await() if (iterResultDone(next)) break entries.add(iterResultValue(next)) } @@ -41,7 +41,7 @@ private fun fileText(file: JsAny): kotlin.js.Promise = js("file.text()") private fun jsStringValue(v: JsAny): String = js("String(v)") internal suspend fun readOpfsFile(fileHandle: JsAny): String? = try { - val file = fileHandleGetFile(fileHandle).await() + val file: JsAny = fileHandleGetFile(fileHandle).await() jsStringValue(fileText(file).await()) } catch (e: Throwable) { null @@ -62,9 +62,9 @@ internal suspend fun opfsWriteFile(path: String, content: String) { } val fileName = parts.last() val fileHandle = getFileHandle(dir, fileName, true) - val writable = fileHandleCreateWritable(fileHandle).await() - writableWrite(writable, content).await() - writableClose(writable).await() + val writable: JsAny = fileHandleCreateWritable(fileHandle).await() + @Suppress("UNUSED_VARIABLE") val _write: JsAny = writableWrite(writable, content).await() + @Suppress("UNUSED_VARIABLE") val _close: JsAny = writableClose(writable).await() } catch (e: Throwable) { println("[SteleKit] OPFS write failed for $path: ${e.message}") } @@ -79,7 +79,7 @@ internal suspend fun opfsDeleteFile(path: String) { dir = getDirectoryHandle(dir, part, false) } val fileName = parts.last() - dirRemoveEntry(dir, fileName).await() + @Suppress("UNUSED_VARIABLE") val _remove: JsAny = dirRemoveEntry(dir, fileName).await() } catch (e: Throwable) { println("[SteleKit] OPFS delete failed for $path: ${e.message}") } From b65fb98e8fe932847f79bde82872b7ff2bc0e404 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Thu, 7 May 2026 16:21:53 -0700 Subject: [PATCH 7/8] fix(web): address review comments on OPFS driver and file system - PlatformFileSystem.listFiles/listDirectories: return names not full paths, matching JVM behavior (callers re-prefix with the parent path) - PlatformFileSystem.directoryExists: check cache instead of always true - DriverFactory.js: guard Schema.create in try/catch so persisted OPFS databases don't fail on second load when tables already exist - Main.kt: check driver.actualBackend == "memory" after createDriverAsync to detect silent worker fallback and switch to IN_MEMORY backend Co-Authored-By: Claude Sonnet 4.6 --- .../kotlin/dev/stapler/stelekit/browser/Main.kt | 9 +++++++-- .../kotlin/dev/stapler/stelekit/db/DriverFactory.js.kt | 7 ++++++- .../dev/stapler/stelekit/platform/PlatformFileSystem.kt | 8 +++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/browser/Main.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/browser/Main.kt index a5c15202..7c9525ae 100644 --- a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/browser/Main.kt +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/browser/Main.kt @@ -29,8 +29,13 @@ fun main() { val driverFactory = DriverFactory() val backend = try { - driverFactory.createDriverAsync(graphId) - GraphBackend.SQLDELIGHT + val driver = driverFactory.createDriverAsync(graphId) + if (driver.actualBackend == "memory") { + println("[SteleKit] OPFS worker fell back to :memory: — data will not persist") + GraphBackend.IN_MEMORY + } else { + GraphBackend.SQLDELIGHT + } } catch (e: Throwable) { println("[SteleKit] OPFS driver init failed, using IN_MEMORY: ${e.message}") GraphBackend.IN_MEMORY diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/DriverFactory.js.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/DriverFactory.js.kt index a4931a47..2839022e 100644 --- a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/DriverFactory.js.kt +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/DriverFactory.js.kt @@ -15,10 +15,15 @@ actual class DriverFactory actual constructor() { actual fun getDatabaseDirectory(): String = "/stelekit" suspend fun createDriverAsync(graphId: String): WasmOpfsSqlDriver { + check(cachedDriver == null) { "createDriverAsync() called twice for graph '$graphId'" } val opfsPath = "/graph-${graphId}.sqlite3" val driver = WasmOpfsSqlDriver(workerScriptPath = "./sqlite-stelekit-worker.js") driver.init(opfsPath) - SteleDatabase.Schema.create(driver).await() + try { + SteleDatabase.Schema.create(driver).await() + } catch (_: Throwable) { + // Tables already exist on a persisted OPFS database — treat as benign. + } MigrationRunner.applyAll(driver) cachedDriver = driver return driver diff --git a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/PlatformFileSystem.kt b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/PlatformFileSystem.kt index 0f6f0b89..7b436b93 100644 --- a/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/PlatformFileSystem.kt +++ b/kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/PlatformFileSystem.kt @@ -53,14 +53,15 @@ actual class PlatformFileSystem actual constructor() : FileSystem { actual override fun readFile(path: String): String? = cache[path] actual override fun fileExists(path: String): Boolean = cache.containsKey(path) actual override fun listFiles(path: String): List = - cache.keys.filter { it.startsWith("$path/") && !it.removePrefix("$path/").contains('/') } + cache.keys + .filter { it.startsWith("$path/") && !it.removePrefix("$path/").contains('/') } + .map { it.removePrefix("$path/") } actual override fun listDirectories(path: String): List = cache.keys .filter { it.startsWith("$path/") } .map { it.removePrefix("$path/").substringBefore('/') } .filter { it.isNotEmpty() && cache.keys.any { k -> k.startsWith("$path/$it/") } } .distinct() - .map { "$path/$it" } actual override fun writeFile(path: String, content: String): Boolean { cache[path] = content @@ -68,7 +69,8 @@ actual class PlatformFileSystem actual constructor() : FileSystem { return true } - actual override fun directoryExists(path: String): Boolean = true + actual override fun directoryExists(path: String): Boolean = + path == homeDir || cache.keys.any { it.startsWith("$path/") } actual override fun createDirectory(path: String): Boolean = true actual override fun deleteFile(path: String): Boolean { cache.remove(path) From def5f306ff3b24e798dc4e15adcf00aac89c94e7 Mon Sep 17 00:00:00 2001 From: Tyler Stapler Date: Thu, 7 May 2026 18:46:16 -0700 Subject: [PATCH 8/8] fix(android): use QueryResult.Value in MigrationRunner mapper to fix AndroidSqliteDriver crash AndroidSqliteDriver.executeQuery calls QueryResult.getValue() on the mapper result. The previous mapper returned QueryResult.AsyncValue, which inherits DefaultImpls.getValue() that throws "The driver used with SQLDelight is asynchronous". AndroidCursor.next() and JsRowCursor.next() both return QueryResult.Value, so cursor.next().value is safe on all platforms. Co-Authored-By: Claude Sonnet 4.6 --- .../dev/stapler/stelekit/db/MigrationRunner.kt | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 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 b203a7a8..8dbae2bc 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/MigrationRunner.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/MigrationRunner.kt @@ -201,19 +201,21 @@ object MigrationRunner { ).await() // Load both name and hash so we can detect tampering. + // Use QueryResult.Value (not AsyncValue) so synchronous drivers (AndroidSqliteDriver) + // can call .getValue() on the mapper result without throwing. + // Both AndroidCursor.next() and JsRowCursor.next() return QueryResult.Value, so + // cursor.next().value is safe across all platforms. val appliedByName: Map = driver.executeQuery( identifier = null, sql = "SELECT name, hash FROM schema_migrations", mapper = { cursor -> - QueryResult.AsyncValue { - val map = mutableMapOf() - while (cursor.next().await()) { - val name = cursor.getString(0) - val hash = cursor.getString(1) - if (name != null && hash != null) map[name] = hash - } - map as Map + val map = mutableMapOf() + while (cursor.next().value) { + val name = cursor.getString(0) + val hash = cursor.getString(1) + if (name != null && hash != null) map[name] = hash } + QueryResult.Value(map as Map) }, parameters = 0 ).await()