Skip to content

feat(web): OPFS-backed SQLite driver + local dev script#74

Merged
tstapler merged 8 commits intomainfrom
stelekit-web-work
May 8, 2026
Merged

feat(web): OPFS-backed SQLite driver + local dev script#74
tstapler merged 8 commits intomainfrom
stelekit-web-work

Conversation

@tstapler
Copy link
Copy Markdown
Owner

@tstapler tstapler commented May 7, 2026

What?

Implements durable browser storage for the SteleKit WASM target, replacing the hard-coded IN_MEMORY + DemoFileSystem demo stack with a real SQLite database persisted in the browser's Origin Private File System (OPFS). Also adds a single-command local dev script.

Why?

The browser app previously lost all data on every page reload — the DriverFactory.js.kt stub threw UnsupportedOperationException with a "Phase B" TODO. This PR completes Phase B: graph data now survives reloads and is isolated per origin. Additionally, there was no convenient way to build and preview the WASM app locally.

How?

Storage architecture:

  • sqlite-stelekit-worker.js — ES module Web Worker that initialises @sqlite.org/sqlite-wasm with the opfs-sahpool VFS (3–4× faster than the async proxy VFS, no SharedArrayBuffer required, Safari 16.4+ compatible)
  • WasmOpfsSqlDriver — custom SqlDriver bridging Kotlin/WASM ↔ worker via postMessage/Promise. All ops return QueryResult.AsyncValue (requires generateAsync = true)
  • DriverFactory.js.ktcreateDriverAsync() pre-initialises the driver before the Compose tree starts; caches it so createDriver() (called by RepositoryFactory) returns the same instance
  • PlatformFileSystem (wasmJs) — in-memory cache pre-loaded from OPFS at startup; writes are cache-first with async OPFS write-through
  • Fallback: if OPFS is unavailable (Firefox/Safari private browsing), the worker posts backend: 'memory' and the app falls back to IN_MEMORY silently

MigrationRunner: applyAll() is now suspend; JVM/Android call sites wrapped in runBlocking (no behaviour change on those platforms).

Local dev: scripts/serve-web.sh builds :kmp:wasmJsBrowserDistribution -PenableJs=true then starts node e2e/server.mjs pointing at the output.

Testing

New e2e tests (e2e/tests/demo.spec.ts)

  • beforeEach clears the OPFS stelekit directory for test isolation
  • Persistence test — navigates to /, waits for window.__stelekit_ready, reloads, waits again, asserts the OPFS stelekit directory exists (proves the driver wrote to OPFS, not just memory)

Existing test

The original canvas-renders test is unchanged and still runs first.

How to test locally

./scripts/serve-web.sh        # builds + opens http://localhost:8787
# In another terminal:
cd e2e && npm ci && npx playwright test

Manual verification

  1. Open http://localhost:8787
  2. Create a note in today's journal
  3. Reload the page
  4. Confirm the note is still present

Type of Change

  • New feature (non-breaking change that adds functionality)
  • Refactoring (no functional changes — MigrationRunner async-ification)

Follow-up Required (R2 risk)

After the first CI build, verify that both of these files appear in kmp/build/dist/wasmJs/productionExecutable/:

  • sqlite-stelekit-worker.js (from wasmJsMain/resources/)
  • sqlite3.wasm (from @sqlite.org/sqlite-wasm npm package)

If the worker path is different in the webpack output, update the workerScriptPath literal in DriverFactory.js.kt.

Files Changed

File Change
scripts/serve-web.sh New — local dev convenience script
kmp/build.gradle.kts Add @sqlite.org/sqlite-wasm@3.46.1 npm dep; enable generateAsync = true
kmp/src/wasmJsMain/resources/sqlite-stelekit-worker.js New — OPFS worker
kmp/src/wasmJsMain/.../db/SqliteWorkerInterop.kt New — js(...) interop helpers
kmp/src/wasmJsMain/.../db/JsBindCollector.kt New — SqlPreparedStatement impl
kmp/src/wasmJsMain/.../db/JsRowCursor.kt New — SqlCursor impl over JS row objects
kmp/src/wasmJsMain/.../db/WasmOpfsSqlDriver.kt New — full SqlDriver via worker
kmp/src/wasmJsMain/.../db/DriverFactory.js.kt Replace stub with caching async init
kmp/src/wasmJsMain/.../platform/OpfsInterop.kt New — OPFS suspend helpers
kmp/src/wasmJsMain/.../platform/PlatformFileSystem.kt Replace stub with cache+OPFS
kmp/src/wasmJsMain/.../browser/Main.kt Async OPFS init outside Compose tree
kmp/src/commonMain/.../db/MigrationRunner.kt applyAllsuspend fun
kmp/src/jvmMain/.../db/DriverFactory.jvm.kt Wrap applyAll in runBlocking
kmp/src/androidMain/.../db/DriverFactory.android.kt Wrap applyAll in runBlocking
e2e/tests/demo.spec.ts Add persistence test + beforeEach OPFS clearing

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 7, 2026 13:01
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 <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds durable, browser-persistent storage for the WASM target by introducing an OPFS-backed SQLite worker/driver, replaces the previous demo-only in-memory stack, and includes a convenience script for local web dev.

Changes:

  • Add a Web Worker (@sqlite.org/sqlite-wasm) using OPFS SAH pool VFS and a Kotlin/WASM SqlDriver bridge returning async QueryResults.
  • Replace WASM stubs with OPFS-backed PlatformFileSystem + async driver initialization before starting the Compose tree.
  • Make MigrationRunner.applyAll() suspend and adapt JVM/Android call sites; add Playwright OPFS persistence coverage and a local serve script.

Reviewed changes

Copilot reviewed 23 out of 23 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
scripts/serve-web.sh Build WASM distribution and start local server pointing at the dist dir.
kmp/build.gradle.kts Add sqlite-wasm npm dep and enable SQLDelight async result generation.
kmp/src/wasmJsMain/resources/sqlite-stelekit-worker.js New sqlite-wasm worker using OPFS SAH pool VFS with in-memory fallback.
kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/SqliteWorkerInterop.kt JS interop helpers for worker messaging and row/value access.
kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/JsBindCollector.kt Prepared-statement binder collecting parameters into a JS array.
kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/JsRowCursor.kt Cursor implementation over worker-returned JS row objects.
kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/WasmOpfsSqlDriver.kt New SQLDelight driver that proxies SQL execution to the worker.
kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/db/DriverFactory.js.kt Replace stub with cached async driver init + schema/migrations.
kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/OpfsInterop.kt OPFS read/write/delete helpers via navigator.storage.getDirectory().
kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/platform/PlatformFileSystem.kt Replace stub with OPFS-preloaded in-memory cache and async write-through.
kmp/src/wasmJsMain/kotlin/dev/stapler/stelekit/browser/Main.kt Perform OPFS preload + driver init before Compose window starts.
kmp/src/commonMain/kotlin/dev/stapler/stelekit/db/MigrationRunner.kt Make applyAll suspend and await async SQLDelight operations.
kmp/src/jvmMain/kotlin/dev/stapler/stelekit/db/DriverFactory.jvm.kt Wrap schema create + migrations in runBlocking for suspend migration runner.
kmp/src/androidMain/kotlin/dev/stapler/stelekit/db/DriverFactory.android.kt Wrap migrations in runBlocking for suspend migration runner.
kmp/src/androidMain/kotlin/dev/stapler/stelekit/db/DriverFactory.android.kt.tmp.4573.1775983611619 Delete stray temporary file from the repo.
e2e/tests/demo.spec.ts Clear OPFS between tests and add persistence-across-reload test.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

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<String> =
cache.keys.filter { it.startsWith("$path/") && !it.removePrefix("$path/").contains('/') }
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: listFiles() now strips the path prefix and returns just names via .map { it.removePrefix("$path/") }.

.map { it.removePrefix("$path/").substringBefore('/') }
.filter { it.isNotEmpty() && cache.keys.any { k -> k.startsWith("$path/$it/") } }
.distinct()
.map { "$path/$it" }
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: listDirectories() now returns directory names only, matching the JVM implementation.

Comment on lines 71 to 72
actual override fun directoryExists(path: String): Boolean = true
actual override fun createDirectory(path: String): Boolean = true
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: directoryExists() now checks the cache (path == homeDir || cache.keys.any { it.startsWith("$path/") }) instead of always returning true.

Comment on lines +21 to +25
SteleDatabase.Schema.create(driver).await()
MigrationRunner.applyAll(driver)
cachedDriver = driver
return driver
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: SteleDatabase.Schema.create(driver) is now wrapped in a try/catch — the exception is swallowed as benign when tables already exist on a persisted OPFS database.

Comment on lines +29 to 35
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
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the code now checks driver.actualBackend == "memory" after a successful createDriverAsync() call and falls back to GraphBackend.IN_MEMORY when the worker silently fell back to :memory:.

Comment thread e2e/tests/demo.spec.ts
Comment on lines +8 to +16
// 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
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The beforeEach goto is only to gain same-origin access for the OPFS cleanup evaluate call — the app doesn't fully initialize there. The actual test's goto('/') starts the app under the listener. The ordering is intentional; leaving as-is.

Comment on lines +71 to +92
override fun newTransaction(): QueryResult<Transacter.Transaction> = 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<Unit> =
this@WasmOpfsSqlDriver.endTransaction(successful)
}
}

override fun endTransaction(successful: Boolean): QueryResult<Unit> = QueryResult.AsyncValue {
val id = nextMsgId()
val promise = createWorkerResponsePromise(worker, id)
workerPostMessage(worker, buildTransactionEndMessage(id, successful))
promise.await()
Unit
}

override fun currentTransaction(): Transacter.Transaction? = null

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Tracking the active transaction and wiring enclosingTransaction correctly requires more state management in the Wasm driver. Deferring to a follow-up to keep this PR focused on the initial OPFS integration.

…x Detekt violations

- 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<Long>)
- 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 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 7, 2026

JVM Load Benchmark (Desktop)

Synthetic in-memory benchmark measuring load performance for the desktop (JVM) app.
Comparing bae9dbf (this PR) vs 83f4432 (baseline)
Graph config: xlarge — 230 pages

Metric This PR Baseline Delta
Phase 1 TTI ↓ 13ms 10ms +3ms (+30%) ⚠️
Phase 2 background ↓ 4ms 3ms +1ms (+33%) ⚠️
Phase 3 index ↓ 20ms 17ms +3ms (+18%) ⚠️
Total ↓ 36ms 30ms +6ms (+20%) ⚠️
Write p95 (baseline) ↓ 45ms 33ms +12ms (+36%) ⚠️
Write p95 (under load) ↓ n/a n/a
Jank factor ↓ n/a n/a
↓ lower is better
Flamegraphs (this PR) **Allocation** — object allocation pressure (JDBC/SQLite churn)

Alloc flamegraph not available

CPU — method-level hotspots by on-CPU time

CPU flamegraph not available

Top SQL queries by total time (this PR) | table:operation | calls | p50 | p99 | max | total | |-----------------|-------|-----|-----|-----|-------| | `pages:select` | 2 | 1ms | 1ms | 1ms | 1ms |
Top allocation hotspots (this PR) `67.6%` byte[]_[k] `4.5%` java.lang.Object[]_[k] `3.9%` jdk.internal.org.objectweb.asm.SymbolTable$Entry_[k] `2.8%` java.lang.String_[k] `2.2%` java.lang.StringBuilder_[k]
Top CPU hotspots (this PR) `99.1%` /usr/lib/x86_64-linux-gnu/libc.so.6 `0.1%` SR_handler `0.1%` pthread_cond_broadcast `0.1%` java/lang/Boolean.booleanValue_[1] `0.1%` inflate_fast

tstapler and others added 5 commits May 7, 2026 13:48
…nd fix param order

With generateAsync=true, SteleDatabase.Schema is SqlSchema<QueryResult.AsyncValue<Unit>>
but AndroidSqliteDriver requires SqlSchema<QueryResult.Value<Unit>>. 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
Kotlin/Wasm cannot infer T for Promise<JsAny>.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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

Android Load Benchmark

Instrumented benchmark on an API 30 x86_64 emulator — 500-page synthetic graph.

Comparing bae9dbf (this PR) vs 83f4432 (baseline)
Device: API 30 x86_64 emulator — 530 pages loaded

Graph Load

Metric This PR Baseline Delta
Phase 1 TTI ↓ 115ms 107ms +8ms (+7%) ⚠️
Phase 3 index ↓ 2441ms 2857ms -416ms (-15%) ✅

Interactive Write Latency (during Phase 3)

Metric This PR Baseline Delta
Write p95 (baseline) ↓ 3ms 2ms +1ms (+50%) ⚠️
Write p95 (during phase 3) ↓ 67ms 95ms -28ms (-29%) ✅
Jank factor ↓ 22.33x 47.5x -25.17x (-53%) ✅
Concurrent writes ↑ 12 12 0 (0%)

SAF I/O Overhead (ContentProvider vs direct File read)

Measures Binder IPC cost added by ContentResolver per readFile() call.
Real SAF via ExternalStorageProvider will be higher on device; this is a lower bound.

Metric This PR Baseline Delta
Direct read / file ↓ 0.0ms 0.0ms 0 (0%)
Provider read / file ↓ 0.3ms 0.2ms +0ms (+35%) ⚠️
IPC overhead ratio ↓ 8x 6x +2x (+33%) ⚠️
↓ lower is better · ↑ higher is better

@tstapler tstapler merged commit 22516f5 into main May 8, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants