Local-First Client & Bootstrap
A local-first Memex host — today Memex.LocalMesh, the sidecar that serves the React
Native shells (its retired predecessor was the in-process MAUI client) — is not a thin
shell around a remote portal. It hosts its own in-process monolith mesh — SQLite node storage + file-system content, fully offline — and joins other meshes (the public portal, a team portal, …) as a SignalR participant. Local data is a first-class mesh; remote portals are other meshes it federates with.
This is the inverse of the server: instead of one big partitioned portal, the client is a small mesh that also participates in bigger ones.
The bootstrap sequence
The client builds the mesh, then uses the mesh to read its own config, then connects out. Order matters — the instance list lives in the mesh, so the mesh must exist before it can be read:
- Bootstrap the monolith —
UseMonolithMesh+ SQLite persistence + file-system content, built into its own MeshWeaver service provider. - Register the device identity — one
AccessContextfor every local operation (single-user mesh). GetQuerythe instance nodes — read theMemexInstancenodes from the local mesh.- Connect the SignalR meshes — for each authenticated instance, dial its
/signalrendpoint and register the stream.
1–2. The raw MeshBuilder bootstrap
The client uses the raw builder (no test base). Two things are easy to get wrong and both throw at runtime:
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
services.AddLogging();
services.AddOptions();
var builder = new MeshBuilder(c => c.Invoke(services), AddressExtensions.CreateMeshAddress("local"))
.UseMonolithMesh()
.AddPartitionedSqlitePersistence($"Data Source={Path.Combine(appData, "memex-local.db")}")
.AddRowLevelSecurity()
.AddGraph() // standard node types (see platform note below)
.AddSpaceType()
.ConfigureServices(s => s.AddFileSystemAssemblyStore(Path.Combine(appData, "assembly-store")));
services.AddSingleton(builder.BuildHub);
// 🚨 CreateMeshWeaverServiceProvider — NOT BuildServiceProvider. The default skips the module
// setup and the hub throws "Mesh Weaver has not been properly configured".
var sp = services.CreateMeshWeaverServiceProvider();
var hub = sp.GetRequiredService<IMessageHub>();
// One device-user identity for every operation (PostPipeline fails closed without a context).
hub.ServiceProvider.GetRequiredService<AccessService>()
.SetHostIdentity(new AccessContext { ObjectId = "device-user", Name = "Device User" });
CreateMeshWeaverServiceProvider(), neverBuildServiceProvider()— it runs the MeshWeaver module setup.- The mesh has its own service provider, separate from the host's DI (the MAUI app resolves the hub from it).
- The address
CreateMeshAddress("local")gives the local mesh the idlocal.
Persistence: nodes in SQLite (MeshWeaver.Hosting.Sqlite — the on-device counterpart to Postgres, which can't run on a phone), content via AddFileSystemContentCollection. See Data Access Patterns.
3. Config as mesh nodes — read with GetQuery
The client's config is not a Preferences/JSON side store — it is mesh nodes. Each connectable mesh is a MemexInstance node (base URL + token) in the local mesh. The bootstrap reads them with hub.GetQuery(...) (per-user RLS query) and binds the UI to them via GetMeshNodeStream / stream.Update — the standard Data Binding pattern. The only on-device non-mesh state is the bootstrap secret (first token in SecureStorage).
4. Connecting to other meshes — a Settings feature of every mesh
Joining another mesh is not client-specific — it is a capability any mesh exposes in Settings: "connect to other meshes." The local client is just the first consumer. Each MemexInstance with a token becomes a SignalR participant connection; once connected, the remote mesh can address this one (render its layout areas, run scripts, message it — the control plane). See SignalR Mesh Participant.
Two planes, kept separate:
| Plane | Mechanism |
|---|---|
| Control — operate a mesh from another | the SignalR participant connection (the connected mesh is addressable) |
| Data — copy a subtree between meshes | Cross-Instance Mirror (mirror push/pull) or ZIP import/export |
⚠️ Transport limit (today): the SignalR client is single-remote.
UseSignalRClientregisters oneHubConnectionand the route resolves that one for every target. "Connect any number of meshes" needs a connection registry keyed by target + route-by-target + a runtimeConnectToMesh(hub, url, token)(the body ofCreateHubConnectionAsync, exposed so step 4 can connect after the mesh is up). Until then, one remote per client.
Platform matrix — what runs where
The client ships the same mesh setup everywhere; only the JIT-dependent features degrade:
| Windows | macOS (Mac Catalyst) | Android | iOS | |
|---|---|---|---|---|
| Local mesh on SQLite, CRUD, query, content | ✅ | ✅ | ✅ | ✅ |
| Dynamic node types / interactive markdown (Roslyn) | ✅ | ✅ (macOS allows JIT; needs com.apple.security.cs.allow-jit) |
✅ mostly | ❌ no JIT |
| Local Postgres | n/a (SQLite) | n/a | n/a | ❌ no fork |
iOS is the only platform that loses Roslyn features, and at runtime — not build. AddGraph() pulls Microsoft.CodeAnalysis, which inflates the iOS AOT bundle. The future iOS cleanup is a narrow split: extract the 6 compilation files in MeshWeaver.Graph/Configuration/ (CompilationInputs, MeshNodeCompilationService, MeshNodeLanguageService, ScriptCompilationService, SourceGeneratorLoader, SpeculativeCompilation) + the Kernel/Roslyn refs into a MeshWeaver.Graph.Compilation assembly behind an INodeTypeCompiler abstraction, registered only where JIT exists — not all of Graph (its types/icons/layout/data-source wiring are Roslyn-free).
Threading note: iOS forbids fork() (→ no Postgres) and JIT (→ no Roslyn), but threads are fine — the actor hubs, IIoPool, and Rx all run. See Controlled IO Pooling and Asynchronous Calls.
Local layout-area reads — the reduce-ChangeType contract
A native renderer reading a LOCAL area (the retired MAUI view pack did; any in-process reader does) binds exactly like the Blazor one: workspace.GetStream(reference).Reduce(new JsonPointerReference("/")).GetControlStream(area).
🚨 A local reduced stream MUST preserve the source ChangeType. The layout render path delivers an area's generator-produced control as a Full snapshot (empty Updates); GetControlStream/GetStream<UiControl> re-evaluates its pointer only on first || ChangeType==Full || a matching Update. StandardReducers.ReduceEntityStoreTo(JsonPointerReference) used to hardcode ChangeType.Patch, turning the Full into a Patch with empty Updates → a control produced after a single-subscribe reader's first frame was dropped forever (GetControlStream returned null). The remote owner→client sync labels snapshots Full, so the two-hub path was immune; the single-hub local reduce violated the contract. Fixed by preserving current.ChangeType (StandardReducers.cs). Blazor masked the latent bug by re-subscribing each render pass; MAUI's RenderArea subscribes once, so it hit it deterministically.
AccessContext on the local-first client — carried, never lost
Application writes from the client (a button click, a chat submit) run off a hub-handler turn, so AccessService.Context (the request-scoped AsyncLocal) is null on that thread. The client still attributes every write to the device user because:
MauiProgramcallsaccessService.SetHostIdentity(deviceUser)once at boot.SetHostIdentityrecords the non-AsyncLocal standing identity of a single-identity host, soAccessService.CircuitContextreturns the device user on every thread/await — there is no Blazor circuit to set the AsyncLocal per inbound activity. 🚨 This API is for processes that serve exactly ONE user (the device client, the xUnit host); a multi-user server must never call it, because the value is process-wide and would hand one user's identity to every other user's context-less read.- Every framework write primitive (
IMeshService.CreateNode,MeshNodeStreamHandle.Update, and thereforehub.SubmitMessage/StartThread) wraps its cold observable withCarryAccessContext, which capturesContext ?? CircuitContexteagerly and re-stamps it on each emission (see Access Context Propagation). On the client that capture resolves to the device user via theCircuitContextfallback.
The rule: submit through the framework primitives (hub.SubmitMessage / StartThread / stream.Update) — never a bespoke hub.Post/wire message from UI code. A bespoke post off a UI thread has no ambient context and is failed closed by the PostPipeline (no identity, no delivery). The native Monaco chat composer reads its text via the WebView and forwards through hub.SubmitMessage, so the user's identity rides along.
See also
- SignalR Mesh Participant — the transport this builds on.
- Cross-Instance Mirror — the data plane.
- The
/layout-areaand/ui-extensibilityskills — how to author UI the implementation-independent way, so a native shell renders it without a Blazor-specific branch.