# Setting Bsqlite up - Blazor Web App - InteractiveWebAssembly with prerendering How this demo is built. Every page is prerendered on the server and then runs on WebAssembly, where the database lives. So the services are registered in BOTH containers - the prerender pass instantiates every page on the server, and whatever a page injects has to resolve there - which is harmless, because nothing touches the browser until a page opens the database. And a page opens it only once `RendererInfo.IsInteractive` is true: during prerendering OnInitializedAsync runs on the server, where there is no browser to open a database in. The demo shares one registration method between the two projects and gates every page through one component; both are below, as they ship. ## Checklist 1. `dotnet add package Bit.Bsqlite` - one package, every storage backend included. In a Blazor Web App, add it to the .Client project. 2. Register with `AddBitBsqliteServices` (namespace `Microsoft.Extensions.DependencyInjection`): each backend with `AddStorageProvider()` - at least one persistent one, OPFS first and IndexedDB as its fallback - the preference order with `UseStorage(s => s.Prefer(...).Fallback(...))` - required, selection never guesses - and the database with `AddDatabase("app.db")`. 3. Register the context with `AddBitBsqliteDbContext()`. Migrations run once per session by default (`BsqliteSchemaStrategy.Migrate`); seed through `OnFirstUse`. Do not also call `AddDbContext` or `UseSqlite`: the factory binds every context to the one shared browser database. 4. Inject `IBsqliteDbContextFactory` (namespace `Bit.Bsqlite.EntityFrameworkCore`; the plain `IDbContextFactory` is registered too) and create contexts with `await CreateDbContextAsync()`. 5. Async only: `ToListAsync`, `SaveChangesAsync`, `MigrateAsync`. The synchronous members throw `BsqliteSynchronousApiNotSupportedException` naming the async overload - the engine runs in a worker the main thread cannot wait on. 6. Nothing goes into the host page: the engine, the worker and the VFS modules are static web assets under `_content/Bit.Bsqlite/`, imported on demand - a session downloads the VFS of the backend it selected and no other. 7. Keep the model in a project that references only EF Core Relational, as the demo's Data project does, and scaffold migrations with `dotnet ef` against the stock SQLite provider: the same migrations apply in the browser. ### In a Blazor Web App 8. Register the services in the server project too - the prerender pass resolves everything a page injects. Put the registration in one extension method in the .Client project and call it from both Program.cs files, as the demo's `AddDemoServices` is. 9. Open the database only once the page is interactive: `if (RendererInfo.IsInteractive is false) return;` before the first `CreateDbContextAsync()` (on .NET 8, which has no `RendererInfo`, `OperatingSystem.IsBrowser()`). The demo's `SessionLifecycle` component does it for every page. 10. Render the pages that use the database with InteractiveWebAssembly - the demo sets it once, on ``. 11. Drop live queries and contexts when `BsqliteDatabase.Closing` fires and reload on `Opened`: that is how a page survives the database being moved or replaced underneath it. ## A page, without the demo's helpers The demo routes every page through its own `SessionLifecycle` component and `BrowserDatabase` wrapper (both below). An app needs neither - this is the whole rule: ```razor @page "/orders" @using Bit.Bsqlite.EntityFrameworkCore @using Microsoft.EntityFrameworkCore @inject IBsqliteDbContextFactory Contexts @code { private List? _orders; protected override async Task OnInitializedAsync() { // Prerendering runs this on the server first, where there is no browser to open a database in. // It runs again once the page is interactive on WebAssembly. if (RendererInfo.IsInteractive is false) return; await using var db = await Contexts.CreateDbContextAsync(); _orders = await db.Orders.Where(o => o.Status == OrderStatus.Draft).ToListAsync(); } } ``` ## The files, from this demo Everything below compiles as it ships; rename the projects and namespaces to your own. The host's Program.cs also runs the demo's model on the stock provider for its /api/products endpoint and hosts this MCP server - neither is anything Bsqlite needs. ### `Demo/Client/Extensions/IServiceCollectionExtensions.cs` ```csharp using Bit.Bsqlite.Demo.Client; using Bit.Bsqlite.Demo.Data; using Bit.Bsqlite.Storage.CacheStorage; using Bit.Bsqlite.Storage.InMemory; using Bit.Bsqlite.Storage.IndexedDb; using Bit.Bsqlite.Storage.Opfs; namespace Microsoft.Extensions.DependencyInjection; /// /// The services shared by the WebAssembly client and the prerendering host. Both containers have to /// register the same set: every page is instantiated once on the server for the prerender pass and again in /// the browser, so anything a page injects must resolve in both places or prerendering fails with a /// missing-service exception. /// /// /// Resolving them on the server is harmless: nothing here touches the browser until a page asks it to open /// the database, and every page does that only once it runs interactively (see Components/SessionLifecycle.razor). /// public static class IServiceCollectionExtensions { /// /// The app's own origin, for the a page can call the host with. The prerendering /// host passes nothing: a page never issues a request while it is being prerendered. /// public static IServiceCollection AddDemoServices(this IServiceCollection services, string? baseAddress = null) { services.AddScoped(_ => baseAddress is null ? new HttpClient() : new HttpClient { BaseAddress = new Uri(baseAddress) }); // One call registers selection, sticky bindings in localStorage, the worker transport, and the // shared database. Existing data outranks preference: a file created on IndexedDB stays there even // once OPFS becomes available, unless the application migrates it deliberately. services.AddBitBsqliteServices(sqlite => sqlite .AddStorageProvider() .AddStorageProvider() .AddStorageProvider() .AddStorageProvider() .UseStorage(s => s .Prefer(BsqliteOpfsStorageProvider.ProviderName) .Fallback(BsqliteIndexedDbStorageProvider.ProviderName) .Fallback(BsqliteCacheStorageProvider.ProviderName) .Fallback(BsqliteInMemoryStorageProvider.ProviderName) .AllowNonPersistentFallback()) .AddDatabase(BrowserDatabase.DatabaseName)); // Contexts come from a factory bound to that database. Migrations run once per session, then the // demo catalog is seeded if the database is empty. services.AddBitBsqliteDbContext(options => options.OnFirstUse = (db, ct) => DemoData.SeedIfEmptyAsync(db, ct)); // Scoped like the library's database it wraps: one for the app in the browser, one per request on the // prerendering host. services.AddScoped(); return services; } } ``` ### `Demo/Server/Program.cs` ```csharp // The host of the demo: a Blazor Web App that prerenders the client's pages and then runs them in the // browser on WebAssembly (InteractiveWebAssembly), where every database the site opens lives. // // It also runs the demo's AppDbContext itself, with the stock UseSqlite provider: the same entities, the same // configurations and the same migrations as the browser applies through UseBitBsqlite, which is the claim the // demo exists to show. using Bit.Bsqlite.Demo.Data; using Bit.Bsqlite.Demo.Server.Components; using Bit.Bsqlite.Demo.Server.Controllers; using Bit.Bsqlite.Demo.Server.Services; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using ModelContextProtocol.Protocol; using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorComponents() .AddInteractiveWebAssemblyComponents(); // The prerender pass instantiates the client's pages in this container, so it registers the very same // services the WebAssembly container does - Bsqlite's included. Nothing browser-bound runs here: every page // opens its database only once it is interactive. builder.Services.AddDemoServices(); // The MCP server (Controllers/McpController.cs) and the plain HTTP endpoints that mirror it - the same // methods, reachable from a browser, which is what the /mcp-server demo page calls to show them live. That // page's route has to differ from MapMcp's below: two literal endpoints on /mcp would make a GET of it // ambiguous. A member with nothing in it is left out of the JSON, exactly as the MCP transport leaves it out, // so the browser view of a tool is the answer an agent gets rather than that answer plus a column of nulls. builder.Services.AddControllers() .AddJsonOptions(options => options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull); builder.Services.AddMcpServer(options => { // The name a person sees in their client's server list, and the version they would quote in a bug // report - the library's, not this web app's, because the library is what the answers are about. options.ServerInfo = new Implementation { Name = "bit-bsqlite", Title = "Bit.Bsqlite", Version = BsqliteServerInstructions.BsqliteVersion, Description = "Documentation, public API, storage backends, setup guides, a storage-selection planner and a SQL guard check for Bit.Bsqlite, SQLite in the browser for Blazor with EF Core on top.", WebsiteUrl = "https://github.com/bitfoundation/bitplatform/tree/develop/src/Utilities/Bsqlite" }; // Returned from `initialize` and put in front of the model before it has called anything. options.ServerInstructions = BsqliteServerInstructions.Text; }) .WithHttpTransport() .WithToolsFromAssembly() .WithResourcesFromAssembly() .WithPromptsFromAssembly() // Every key this server takes - a document name, a type name, a backend, a scenario, a source path - comes // from a closed set nothing on the wire spells out; completions let a client type ahead into it. .WithCompleteHandler((request, cancellationToken) => ValueTask.FromResult(new CompleteResult { Completion = BsqliteCompletions.Complete(request.Params?.Argument?.Name, request.Params?.Argument?.Value) })) // Additive to the attributed resources, not a replacement for them: a template cannot be listed as // something to click, so the documents and the setup guides are enumerated one by one as well. .WithListResourcesHandler((request, cancellationToken) => ValueTask.FromResult(new ListResourcesResult { Resources = [.. McpResources.ListDocuments()] })); // The server's copy of the catalog, in memory, so the deployed demo writes nothing to its server's disk and a // restart starts it over. A named shared-cache database lives as long as one connection to it is open: this // one is held for the lifetime of the app, and every context opens a connection of its own next to it, so // concurrent requests never share one SqliteConnection. const string serverDatabase = "Data Source=bsqlite-demo;Mode=Memory;Cache=Shared"; var keepAlive = new SqliteConnection(serverDatabase); keepAlive.Open(); builder.Services.AddSingleton(keepAlive); builder.Services.AddDbContext(options => options.UseSqlite(serverDatabase)); builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.All; options.ForwardedHostHeaderName = "X-Host"; options.KnownIPNetworks.Clear(); options.KnownProxies.Clear(); options.ForwardLimit = 1; }); var app = builder.Build(); // The migrations the browser applies are the ones applied here, by the stock provider. await using (var scope = app.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync(); await DemoData.SeedIfEmptyAsync(db); } if (app.Environment.IsDevelopment() is false) { app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseHsts(); } // A deep link to an unknown route matches no Razor component endpoint, so routing answers with an empty 404. // Re-execute it through the app to get the page the router shows for the same miss during client-side // navigation - keeping the status code at 404. app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); app.UseForwardedHeaders(); app.UseHttpsRedirection(); app.UseAntiforgery(); app.MapStaticAssets(); // Both are literal routes, so they are matched before any component route regardless of order; declaring // them first says so out loud. app.MapControllers(); app.MapMcp("/mcp"); // The server side of the shared model: the catalog the stock provider holds, through the same AppDbContext. // Read-only: the site is public and the database lives in this process's memory for its whole lifetime, so an // endpoint that let anyone add rows would let anyone grow it until the process runs out of memory. app.MapGet("/api/products", async (AppDbContext db, CancellationToken ct) => await db.Products.AsNoTracking().OrderBy(p => p.Id).ToListAsync(ct)); app.MapRazorComponents() .AddInteractiveWebAssemblyRenderMode() .AddAdditionalAssemblies(typeof(Bit.Bsqlite.Demo.Client._Imports).Assembly); app.Run(); /// /// Named so the tests can host this exact file in-memory. Top-level statements compile to an internal /// Program, which WebApplicationFactory cannot reach from another assembly on its own. /// public partial class Program; ``` ### `Demo/Client/Program.cs` ```csharp // The WebAssembly half of the demo: the same service registration as the server, through AddDemoServices. // Every database this site opens lives here, in the browser tab. using Bit.Bsqlite.Demo.Client; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; var builder = WebAssemblyHostBuilder.CreateDefault(args); builder.Services.AddDemoServices(builder.HostEnvironment.BaseAddress); TaskScheduler.UnobservedTaskException += (_, args) => SampleLog.Exception(args.Exception); AppDomain.CurrentDomain.UnhandledException += (_, args) => { if (args.ExceptionObject is Exception exception) { SampleLog.Exception(exception); } else { Console.Error.WriteLine($"[Bsqlite demo] Unhandled: {args.ExceptionObject}"); } }; // No RootComponents are registered on purpose: the host page drives the app through blazor.web.js // render-mode markers (). await builder.Build().RunAsync(); ``` ### `Demo/Server/Components/App.razor` ```razor @* No static here: every page supplies its own through <PageTitle>, and with prerendering the HeadOutlet emits a real <title> element into this head. *@ <meta name="description" content="bit Bsqlite (Bit.Bsqlite) - EF Core on SQLite, inside the browser tab. The same entities and migrations on the client as on the server, over OPFS, IndexedDB, Cache Storage, or memory." /> @* Link previews: the card a chat client or a social site unfurls is the first impression a shared link makes. *@ <meta property="og:type" content="website" /> <meta property="og:site_name" content="Bit.Bsqlite" /> <meta property="og:title" content="Bsqlite - EF Core on SQLite, inside the browser tab" /> <meta property="og:description" content="SQLite in a Web Worker under the stock EF Core provider: the same entities and migrations as the server, live queries, and a database that lives in OPFS, IndexedDB, Cache Storage or memory." /> <meta name="twitter:card" content="summary" /> <ResourcePreloader /> @* The site kit (app.css, theme.js, site.js - shared by every Utilities demo), then this demo's own sample styles. The library has no stylesheet: it draws nothing. *@ <link rel="stylesheet" href="@Assets["app.css"]" /> <link rel="stylesheet" href="@Assets["demo.css"]" /> @* Scoped-CSS bundle: includes the client project's *.razor.css files as well. *@ <link rel="stylesheet" href="@Assets["Bit.Bsqlite.Demo.Server.styles.css"]" /> @* Synchronous on purpose: applies the light/dark theme before first paint. *@ <script src="@Assets["theme.js"]"></script> <ImportMap /> <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%231276C6'/%3E%3Cellipse cx='16' cy='9' rx='9' ry='3.6' fill='none' stroke='%23fff' stroke-width='2.2'/%3E%3Cpath d='M7 9v14c0 2 4 3.6 9 3.6s9-1.6 9-3.6V9' fill='none' stroke='%23fff' stroke-width='2.2'/%3E%3Cpath d='M7 16c0 2 4 3.6 9 3.6s9-1.6 9-3.6' fill='none' stroke='%23fff' stroke-width='2.2'/%3E%3C/svg%3E" /> @* Same render mode as the app itself, so <PageTitle> keeps working after hydration. *@ <HeadOutlet @rendermode="InteractiveWebAssembly" /> </head> <body> @* Prerendered on the server, then hydrated by the WebAssembly runtime. Every database this site opens lives in the browser - OPFS, IndexedDB, Cache Storage or memory - so none of it can open during the prerender pass: each page is readable straight from the served HTML, and its data loads once the runtime is interactive (Components/SessionLifecycle.razor). InteractiveWebAssembly rather than Auto or Server for the same reason: the worker that owns the database has to run in the user's tab. *@ <Routes @rendermode="InteractiveWebAssembly" /> <div id="blazor-error-ui" data-nosnippet> An unhandled error has occurred. <a href="." class="reload">Reload</a> <span class="dismiss">🗙</span> </div> @* demo.js before the Blazor script so window.bsqliteDemo exists by the time the app boots. The library's own modules (the worker host, the engine, the VFSes) are imported on demand and need no tag. *@ <script src="@Assets["demo.js"]"></script> <script src="@Assets["_framework/blazor.web.js"]"></script> <script src="@Assets["site.js"]"></script> </body> </html> ``` ### `Demo/Client/Components/SessionLifecycle.razor` ```razor @inject BrowserDatabase Database @implements IAsyncDisposable @ChildContent @code { [Parameter] public RenderFragment? ChildContent { get; set; } [Parameter] public EventCallback OnReady { get; set; } [Parameter] public EventCallback OnTearDown { get; set; } protected override async Task OnInitializedAsync() { // Every page is prerendered on the server first, where there is no browser to open a database in. // The page renders its static content there; the database opens once it runs in the browser. if (RendererInfo.IsInteractive is false) { return; } Database.BeforeSessionReset += TearDownAsync; Database.AfterSessionReset += ReadyAsync; // Opening raises AfterSessionReset, which runs ReadyAsync once for every subscriber; only // an already-open database needs the explicit call. if (Database.Database.IsOpen) { await ReadyAsync(); } else { await Database.EnsureSessionAsync(); } } private async Task TearDownAsync() { if (OnTearDown.HasDelegate) { await OnTearDown.InvokeAsync(); } } private async Task ReadyAsync() { if (OnReady.HasDelegate) { await OnReady.InvokeAsync(); } } public async ValueTask DisposeAsync() { Database.BeforeSessionReset -= TearDownAsync; Database.AfterSessionReset -= ReadyAsync; await TearDownAsync(); } } ``` ### `Demo/Client/BrowserDatabase.cs` ```csharp using Bit.Bsqlite.Demo.Data; using Bit.Bsqlite; using Bit.Bsqlite.Data; using Bit.Bsqlite.EntityFrameworkCore; using Bit.Bsqlite.Interop; using Bit.Bsqlite.Storage; namespace Bit.Bsqlite.Demo.Client; public enum StorageSwitchMode { /// <summary>Copy the SQLite image, flip the sticky binding, delete the source.</summary> Migrate, /// <summary>Leave the old file (if any) and open an empty database on the target backend.</summary> Fresh, } /// <summary> /// The sample's view of the library's shared <see cref="BsqliteDatabase"/>: hands out contexts /// and drives the storage-switching UI. Everything that is not UI plumbing lives in the library. /// </summary> public sealed class BrowserDatabase( BsqliteDatabase database, IBsqliteDbContextFactory<AppDbContext> contexts, IEnumerable<IBsqliteStorageProvider> providers, IBsqliteStorageBindingStore bindings, BsqliteStorageMigrator migrator) { public const string DatabaseName = "app.db"; public BsqliteDatabase Database { get; } = database; public IReadOnlyList<IBsqliteStorageProvider> Providers { get; } = [.. providers]; public IBsqliteStorageBindingStore Bindings { get; } = bindings; public BsqliteSession? Session => Database.Session; public BsqliteStorageResolution? Resolution => Database.Resolution; public BsqliteConnection? Connection => Database.Connection; /// <summary>Raised before the worker is torn down so pages can drop live queries and contexts.</summary> public event Func<Task>? BeforeSessionReset { add => Database.Closing += value; remove => Database.Closing -= value; } /// <summary>Raised after a session opens so pages can (re)load.</summary> public event Func<Task>? AfterSessionReset { add => Database.Opened += value; remove => Database.Opened -= value; } public async Task<BsqliteSession> EnsureSessionAsync(CancellationToken cancellationToken = default) => await Database.OpenAsync(cancellationToken); public Task<AppDbContext> OpenContextAsync(CancellationToken cancellationToken = default) => contexts.CreateDbContextAsync(cancellationToken); /// <summary> /// Moves or recreates <see cref="DatabaseName"/> on <paramref name="targetProviderName"/>. /// </summary> public async Task SwitchStorageAsync( string targetProviderName, StorageSwitchMode mode, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(targetProviderName); var target = Providers.FirstOrDefault(p => string.Equals(p.Name, targetProviderName, StringComparison.OrdinalIgnoreCase)) ?? throw new InvalidOperationException($"No registered provider named '{targetProviderName}'."); var source = Resolution?.Provider ?? await ProviderFromBindingAsync(cancellationToken); await Database.WhileClosedAsync(async ct => { if (mode is StorageSwitchMode.Migrate && source is not null && !string.Equals(source.Name, target.Name, StringComparison.OrdinalIgnoreCase) && await source.Admin.ExistsAsync(DatabaseName, ct)) { await migrator.MigrateAsync(DatabaseName, source, target, Bindings, ct); } else { await target.Admin.DeleteAsync(DatabaseName, ct); await Bindings.SetProviderNameAsync(DatabaseName, target.Name, ct); } }, cancellationToken); await Database.OpenAsync(cancellationToken); } /// <summary> /// Tears the worker down so admin import/delete can replace the file without racing an open /// connection. The next <see cref="OpenContextAsync"/> opens a fresh session. /// </summary> public Task CloseSessionAsync() => Database.CloseAsync(); public async Task<object?> ExecuteScalarAsync( string sql, CancellationToken cancellationToken = default) { var session = await EnsureSessionAsync(cancellationToken); await using var command = session.Connection.CreateCommand(); command.CommandText = sql; return await command.ExecuteScalarAsync(cancellationToken); } public async Task ExecuteNonQueryAsync( string sql, CancellationToken cancellationToken = default) { var session = await EnsureSessionAsync(cancellationToken); await using var command = session.Connection.CreateCommand(); command.CommandText = sql; await command.ExecuteNonQueryAsync(cancellationToken); } private async Task<IBsqliteStorageProvider?> ProviderFromBindingAsync( CancellationToken cancellationToken) { var name = await Bindings.GetProviderNameAsync(DatabaseName, cancellationToken); return name is null ? null : Providers.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); } } ``` ### `Demo/Client/Routes.razor` ```razor @inject NavigationManager Nav @implements IDisposable @* Every page but the landing page renders inside the docs shell (Layout/DocsLayout.razor); the landing page and the error pages opt out with @layout MainLayout. After a navigation the page's own H1 takes focus, so a screen reader starts reading at the new page. A page that throws while rendering is replaced by UnhandledExceptionView, inside the site's shell so the header still leads elsewhere, and the next navigation recovers the boundary. *@ <Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFoundPage)"> <Found Context="routeData"> <ErrorBoundary @ref="_boundary"> <ChildContent> <RouteView RouteData="routeData" DefaultLayout="typeof(Layout.DocsLayout)" /> </ChildContent> <ErrorContent Context="exception"> <LayoutView Layout="typeof(Layout.MainLayout)"> <UnhandledExceptionView Exception="exception" /> </LayoutView> </ErrorContent> </ErrorBoundary> <FocusOnNavigate RouteData="routeData" Selector="h1" /> </Found> </Router> @code { private ErrorBoundary? _boundary; protected override void OnInitialized() => Nav.LocationChanged += OnLocationChanged; private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => _boundary?.Recover(); public void Dispose() => Nav.LocationChanged -= OnLocationChanged; } ``` ### `Demo/Client/_Imports.razor` ```razor @using System.Net.Http @using System.Net.Http.Json @using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.EntityFrameworkCore @using Microsoft.JSInterop @using Bit.Bsqlite.Demo.Client @using Bit.Bsqlite.Demo.Client.Components @using Bit.Bsqlite.Demo.Client.Layout @using Bit.Bsqlite.Demo.Data @using Bit.Bsqlite.Demo.Domain @using Bit.Bsqlite @using Bit.Bsqlite.Data @using Bit.Bsqlite.Interop @using Bit.Bsqlite.Storage ``` ## From the documentation _readme:_ ### Installation ```bash dotnet add package Bit.Bsqlite ``` That is the whole install - every backend is in it. Browser-free unit tests add `Bit.Bsqlite.Testing` to the test project. Register the services in the project that runs on WebAssembly - and, in a Blazor Web App whose pages are prerendered, in the server project too, so the prerender pass can resolve what the pages inject: ```csharp using Bit.Bsqlite.Storage.IndexedDb; using Bit.Bsqlite.Storage.InMemory; using Bit.Bsqlite.Storage.Opfs; builder.Services.AddBitBsqliteServices(sqlite => sqlite .AddStorageProvider<BsqliteOpfsStorageProvider>() .AddStorageProvider<BsqliteIndexedDbStorageProvider>() .AddStorageProvider<BsqliteInMemoryStorageProvider>() .UseStorage(s => s .Prefer(BsqliteOpfsStorageProvider.ProviderName) .Fallback(BsqliteIndexedDbStorageProvider.ProviderName) .Fallback(BsqliteInMemoryStorageProvider.ProviderName) .AllowNonPersistentFallback()) .AddDatabase("app.db")); builder.Services.AddBitBsqliteDbContext<AppDbContext>(); ``` The engine (`.wasm`), the worker and the VFSes are static web assets under `_content/Bit.Bsqlite/`, each imported on demand: registering a backend costs nothing until it is selected, and a session downloads one engine build and the VFS of the backend it selected. Nothing needs to be added to the host page. _readme:_ ### Where it runs | Host | Support | | --- | --- | | Blazor WebAssembly | Everything | | Blazor Web App, Interactive WebAssembly | Everything | | Blazor Web App, Interactive Auto | Everything once the page runs on WebAssembly | | Prerendering | Register the services on the server as well; they resolve there, and the database opens once the page runs in the browser (`RendererInfo.IsInteractive` on Interactive WebAssembly, `OperatingSystem.IsBrowser()` on Interactive Auto) | | Interactive Server | Not supported: the engine runs in the user's tab, and EF Core on the server would need a native SQLite of its own | Every service that reaches the browser, or holds a database session, is registered as scoped: one for the whole application on WebAssembly, and one per circuit or request on a server, so nothing is ever shared between users. EF Core's own feature set follows the target framework. The SQLite provider translates `SUM` over a `decimal` from EF Core 9 on and `ORDER BY` a `decimal` from EF Core 10 on; an application on .NET 8 gets the same `NotSupportedException` from those queries in the browser as from the stock provider on its server. The rest is ordinary EF Core and ADO.NET - `GetBsqliteDocument(name: "getting-started")` covers contexts, raw ADO.NET and the shared database, `GetBsqliteDocument(name: "live-queries")` queries that re-run themselves, `GetBsqliteSetupGuide(scenario: "storage")` choosing backends, and `GetBsqliteSourceFile()` the demo's working pages.