Skip to content

feat(playground): a guided tutorial that runs EF Core + SQLite in the browser - #643

Merged
pal-tamas merged 3 commits into
mainfrom
worktree-tutorial-playground-sqlite
Aug 8, 2026
Merged

feat(playground): a guided tutorial that runs EF Core + SQLite in the browser#643
pal-tamas merged 3 commits into
mainfrom
worktree-tutorial-playground-sqlite

Conversation

@pal-tamas

Copy link
Copy Markdown
Owner

Summary

Adds a guided tutorial to the playground, whose last four chapters run real EF Core against real SQLite inside the browser tab. A reader can now go from the front page to "I wrote an entity, saved a row and queried it back" with nothing installed — then hand off to docs/tutorial for the parts that need a machine (rask new, migrations, jobs, mail, deploy).

The left pane gains a Tutorial tab beside the example gallery: eight chapters, each with its goal and what to notice above the editor, prev/next navigation, and a tick once the chapter compiles (your edits included — the tick means it built, not you clicked).

# Chapter Teaches DB
1 Your first component Render(), tag factories, the children indexer
2 State and events a field, a handler, automatic re-render
3 Composition and lists a child component's generated factory, Key:
4 Forms and validation Form<T>, two-way Input(() => model.Field), Validate:
5 Your first entity Entity<Guid>, a DbContext, EnsureCreated(), an insert
6 Query and display LINQ over a DbSet, translated to SQL
7 Edit and delete an update, then soft delete and the query filter that hides it
8 Relationships a navigation property, saving a graph, Include()

Chapters 5–8 teach the same Rask.Data conventions rask generate feature scaffolds — Entity<Guid>, ApplyRaskConventions(), the auditing/soft-delete interceptors — so what a reader learns in the browser is what they write on their machine.

The framework bug this uncovered

OnMountAsync never ran for any playground snippet, and never had. A component normally enters the tree through its generated factory, and that factory's GetOrCreate is what registers the instance with its parent. The playground builds each compiled component with ActivatorUtilities, so it arrived as a plain object: it rendered, but was invisible to the alive-set walk — no OnMount, no OnMountAsync, no OnRendered, no OnUnmount, and no handle to re-render through when an async hook completed.

The failure is silent and looks exactly like code that doesn't work. Here it surfaced as chapter 5's EnsureCreatedAsync never running, then the insert reporting no such table: Products — I chased it as a SQLite problem before an io-only probe (no EF Core at all) rendered nothing either.

New public Mount in Rask.Core fixes it: Div()[Mount(Child: instance)] adopts and notifies the instance exactly as the framework's own wrapper roots do for theirs, and adds no markup of its own. Wrapping a factory-built child is a harmless no-op. Rask.Testing already solved this internally for RaskTest.Render (#583); this is the same fix for app code.

Build

SQLite in WASM means linking e_sqlite3 (a static archive) into the runtime — a native relink, so the playground now publishes with wasm-tools. That is gated so the fast paths are untouched:

  • RaskPlaygroundData follows the csproj's existing WasmBuildNative guard (as InvariantGlobalization and RunAOTCompilation already do). The fast no-native build ships without the EF Core reference set and marks chapters 5–8 read-only with Run disabled — rather than pretending they work and filling the preview with CS0246.
  • scripts/run-unit-local.sh and the slnx build in the E2E gate are unchanged — still fast, still workload-free.
  • pages.yml already installs wasm-tools and publishes without the flag, so the deployed site gets the full track with no workflow change.
  • The playground publish in run-e2e-local.sh drops -p:WasmBuildNative=false, and also drops --no-restore: RaskPlaygroundData changes the package graph, and MSBuild silently ignores a PackageReference that appeared after restore — the bundle would have defined RASK_PLAYGROUND_DATA while shipping no EF Core at all.
  • NoWarn=WASM0001 for SQLite's varargs natives (sqlite3_config), which warnings-as-errors would otherwise turn into a failed publish.

Testing

  • TutorialChaptersTests — every chapter compiles through the real PlaygroundCompiler, and the data chapters actually reach SQLite: seed, save, query back, with the entity chapter asserting an insert through a real click. Also pins the ch{N}.db convention that Reset derives its file name from, and that pooling stays off.
  • MountTests — pins both directions: an unadopted instance renders but never mounts (the bug), and Mount runs the sync and async lifecycle including the re-render on completion.
  • TutorialPaneStateTests — keeps the E2E's DOM hooks and the markup in step, in the fast gate rather than as a browser timeout (the IdeBadgeState pattern from The browser E2E gate cannot pass on main: the playground test waits for a class #470 deleted #593).
  • PlaygroundExampleTests — the acceptance test, and the only thing that can prove this at all: chapter 5 creates the database, inserts through the compiled component's own handler and renders the row; Next → chapter 6 seeds and queries Cold brew back. Passing against the native publish.
  • dotnet format + warnings-as-errors build + full unit suite green.

Also verified in a real browser via a scripted Playwright pass: the editor survives tab switches (hosts=1, monacoAlive=true, brief never inside the host), the tab loads the code the pane highlights in both directions, and on a no-data build chapter 5 renders is-locked with Run genuinely disabled.

Review

A high-effort /code-review found 10 real defects, all fixed — most seriously the brief band shifting the Monaco host's child slot so the positional diff rewrote the live editor, the tab switch leaving the editor showing a different chapter than the brief claimed, and the --no-restore package-graph mismatch above. Worth noting one dead end the fix comments record: keying the two children looks like the obvious fix and is worse — a keyed host is re-created by the full-document morph, orphaning the editor Monaco mounted into, which I only caught by screenshotting.

Benchmarks: not applicable. The framework diff is one new file (Mount.cs, 49 lines); no existing render or live-runtime path is touched.

Docs

docs/playground.md (tutorial section + honest limitations), docs/composition.md (hosting a component you built yourself), docs/sqlite.md, docs/data-access.md, docs/tutorial/00-overview.md, README.md, llms.txt, CHANGELOG.

docs/sqlite.md's "SQLite in the browser?" section said it isn't viable. It is — this PR ships it — so that caveat is now scoped to what actually still bites (durability, plus the untrimmed and WASM0001 constraints). Durable client-side SQLite over OPFS is being designed separately in #642, so this deliberately leaves that rewrite to it rather than half-doing it here.

… the browser

The playground's left pane gains a Tutorial tab beside the example gallery: eight
chapters from "what is a component" to a working database, each with its goal and
notes above the editor, prev/next navigation, and a tick once it compiles.

Chapters 5-8 run real EF Core against real SQLite inside the tab -- e_sqlite3 is
linked into the published WebAssembly runtime, so SaveChangesAsync writes rows a
later Where(...) reads back through actual SQL. They teach the same Rask.Data
conventions rask generate feature scaffolds (Entity<Guid>, ApplyRaskConventions,
the auditing/soft-delete interceptors), so what a reader learns in the browser is
what they write on their machine.

Each chapter owns its own database file: chapters evolve the schema and
EnsureCreated() does nothing to a database that already has tables. Pooling is off
in the chapter connection strings because a chapter may recreate its database
between runs and a pooled connection keeps serving the deleted file.

The EF Core reference set is gated on RaskPlaygroundData, which follows the
existing WasmBuildNative guard in the csproj: the fast no-native build ships
without it and marks those chapters read-only (Run disabled) rather than
pretending they work, so the unit gate stays fast and workload-free.

The brief band is always rendered, empty on the gallery tab, to keep the Monaco
host at a fixed child slot -- rendering it conditionally would shift the host's
position and let the positional diff rewrite the live editor. Keying the children
instead does not work: a keyed host is re-created by the full-document morph,
orphaning the editor Monaco mounted into.
A component normally enters the tree through its generated factory, and that
factory's GetOrCreate is what registers the instance with its parent. One built
another way -- because its type is not known until runtime: a plugin, a component
chosen by name, one compiled in the browser -- arrives as a plain object. It
rendered correctly but was invisible to the alive-set walk: no OnMount, no
OnMountAsync, no OnRendered, no OnUnmount, and no handle to re-render through when
an async hook completed.

The failure is silent and looks exactly like code that does not work: a component
that loads its data in OnMountAsync sits on its placeholder forever. The playground
mounts every compiled component this way, so until now no playground snippet could
load anything in OnMountAsync -- which is how this was found, when the tutorial's
EnsureCreatedAsync never ran and the later insert reported 'no such table'.

Mount(Child: instance) adopts and notifies it, exactly as the framework's own
wrapper roots do for theirs, and adds no markup of its own. Wrapping a
factory-built child is a harmless no-op.
…ility

The section said browser SQLite isn't viable. It is -- this branch ships it -- so
the caveat now names the two real constraints (untrimmed, NoWarn=WASM0001) and
limits the warning to what actually still bites: durability. Durable client-side
SQLite over OPFS is being designed separately, so this leaves that rewrite to it
rather than half-doing it here.
@pal-tamas
pal-tamas merged commit 498398a into main Aug 8, 2026
10 checks passed
@pal-tamas
pal-tamas deleted the worktree-tutorial-playground-sqlite branch August 8, 2026 06:48
pal-tamas added a commit that referenced this pull request Aug 8, 2026
…its own publish (#652)

Four fixes, all found while verifying the deployed playground after #643.

1. Picking a chapter before the editor mounted silently kept the starter code.
   Run and Reset waited for the editor; the controls that LOAD code did not, so
   loading a chapter was a no-op against an editor that did not exist yet and
   mountEditor then installed the starter over the selection. The brief and the
   highlight showed one chapter while the editor held another, and Run compiled
   the wrong code and ticked the chapter off. Every control now shares one
   condition, because the bug was two copies of it disagreeing. Closes #647.

2. The E2E gate was broken by #643 itself. It publishes the playground with the
   native relink while the solution build compiles it no-native through the same
   obj/, so the scoped-asset bake never reaches the publish: wwwroot/_rask is
   absent, the page 404s on the PlaygroundView.js that owns mountEditor, and every
   journey dies waiting on a permanently disabled Run button. It reads as a hang,
   names no cause, and a single clean run passes -- which is what hid it. Fixed by
   clearing that project's TFM intermediates first, verified by running the gate
   twice back to back.

3. A missing editor module now says so. The mount is an interop call that never
   SETTLES when its module is absent, which is not the same as failing, so the
   textarea fallback never got a chance. It now has a deadline and reports the
   module as missing rather than looking hung.

4. The pre-commit gate now covers samples/ and docs/. Its filter listed src/,
   tests/, benchmarks/ and the build files, so a samples-only or docs-only commit
   reported "no code changes staged" and skipped format and the unit suite --
   despite Rask.Example.Shared.Tests compiling the sample and owning a committed
   markup golden, and DocsIndexTests/GuidesTests reading docs/**/*.md off disk for
   reachability and catalog parity.
pal-tamas added a commit that referenced this pull request Aug 8, 2026
rask generate job is not gated on project kind — it runs wherever a .csproj is found — but it printed
one set of next steps for everyone: point a DbContextFactory at Data Source=app.db, then run
`rask db add && rask db update`.

In a WASM app every line of that is wrong. `rask db` wraps dotnet-ef against a design-time database
and a browser bundle has no migrations assembly, so it is not a step the reader can take. The
registration omits AddRaskBrowserSqlite, without which the database lives in the runtime's in-memory
filesystem. And the failure is silent rather than loud: the app builds, runs, and quietly loses every
queued job on reload.

ProjectContext now detects a browser project the same way it already detects the database provider —
by reading the project file it has already opened — and JobGenerator branches on it. The browser notes
register AddRaskBrowserSqlite first, create the schema at boot rather than through rask db, and name
the two build settings that each break the app without producing an error: publishing with
-p:WasmBuildNative=false (no relink, so no SQLite) and PublishTrimmed=true (EF Core dies in the
trimmer). It also adds Rask.SQLite.Browser to the packages it installs.

Docs follow the same correction. docs/jobs.md said jobs were "not a browser/WASM concern"; they now
are, so it shows the registration and is explicit about what the guarantees are worth there — the
lease elects one TAB, durability is bounded by the snapshot interval rather than by SaveChangesAsync,
and pagehide is best-effort so lease expiry is usually what recovers a batch.

docs/sqlite.md keeps #643's correction that browser SQLite runs, and replaces the paragraph saying
durability is still unsolved — it is solved and merged. The OPFS paragraph is deliberately narrow:
OPFS makes the FLUSH incremental (ranged writes), it does not move the live database out of MEMFS,
because that needs createSyncAccessHandle, which exists only inside a Worker while Rask boots the
runtime on the main thread. Scope confirmed with the #642 session rather than guessed.
pal-tamas added a commit that referenced this pull request Aug 8, 2026
rask generate job is not gated on project kind — it runs wherever a .csproj is found — but it printed
one set of next steps for everyone: point a DbContextFactory at Data Source=app.db, then run
`rask db add && rask db update`.

In a WASM app every line of that is wrong. `rask db` wraps dotnet-ef against a design-time database
and a browser bundle has no migrations assembly, so it is not a step the reader can take. The
registration omits AddRaskBrowserSqlite, without which the database lives in the runtime's in-memory
filesystem. And the failure is silent rather than loud: the app builds, runs, and quietly loses every
queued job on reload.

ProjectContext now detects a browser project the same way it already detects the database provider —
by reading the project file it has already opened — and JobGenerator branches on it. The browser notes
register AddRaskBrowserSqlite first, create the schema at boot rather than through rask db, and name
the two build settings that each break the app without producing an error: publishing with
-p:WasmBuildNative=false (no relink, so no SQLite) and PublishTrimmed=true (EF Core dies in the
trimmer). It also adds Rask.SQLite.Browser to the packages it installs.

Docs follow the same correction. docs/jobs.md said jobs were "not a browser/WASM concern"; they now
are, so it shows the registration and is explicit about what the guarantees are worth there — the
lease elects one TAB, durability is bounded by the snapshot interval rather than by SaveChangesAsync,
and pagehide is best-effort so lease expiry is usually what recovers a batch.

docs/sqlite.md keeps #643's correction that browser SQLite runs, and replaces the paragraph saying
durability is still unsolved — it is solved and merged. The OPFS paragraph is deliberately narrow:
OPFS makes the FLUSH incremental (ranged writes), it does not move the live database out of MEMFS,
because that needs createSyncAccessHandle, which exists only inside a Worker while Rask boots the
runtime on the main thread. Scope confirmed with the #642 session rather than guessed.
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.

1 participant