Firestore Mobile (on-device)
The Shiny.DocumentDb.Firestore.Mobile package provides a document store over the native Firebase Firestore SDK, running on the device. The native SDK owns the hard parts — the local cache, the offline write queue, snapshot listeners, backoff and conflict handling — and this package is a thin typed adapter mapping IDocumentStore onto it.
This is not the Firestore provider
Section titled “This is not the Firestore provider”There are two Firestore providers and they are not interchangeable. Pick by where your code runs:
Shiny.DocumentDb.Firestore |
Shiny.DocumentDb.Firestore.Mobile |
|
|---|---|---|
| SDK | Google.Cloud.Firestore (admin/gRPC) |
Native Firebase SDK (iOS/Android) |
| Runs where | Server / backend host | On the client device |
| Auth | Service account (ADC) | Firebase Auth, per end-user |
| Security rules | Bypassed (admin credentials) | Enforced |
| Offline | None | Native persistent cache — offline by default |
| Registration | AddFirestoreDocumentStore(…) |
AddMobileFirestoreDocumentStore(…) |
When to Use
Section titled “When to Use”- A mobile app that must work offline and sync when connectivity returns
- Per-user data enforced by Firestore security rules rather than a trusted backend
- Live updates pushed from any writer, straight to the device
Platform support
Section titled “Platform support”| Target | Behaviour |
|---|---|
net10.0-android |
Real adapter over the native SDK |
net10.0-ios |
Real adapter over the native SDK |
| Anything else | Throws PlatformNotSupportedException |
Both mobile heads are at feature parity — the same operations work, the same ones throw — and both are verified end-to-end against the Firestore emulator. The net10.0 target is a stub that exists so the surface stays unit-testable without a device; guard multi-targeted code with #if ANDROID || IOS.
Installation
Section titled “Installation”dotnet add package Shiny.DocumentDb.Firestore.Mobile-
Initialise Firebase
Bundle the platform config file —
google-services.json(Android) orGoogleService-Info.plist(iOS) — for auto-init. The store throwsInvalidOperationExceptionif Firebase is not initialised by the time it is resolved. -
Register the store
using Shiny.DocumentDb;builder.Services.AddMobileFirestoreDocumentStore(o =>{o.ProjectId = "my-project"; // optional when the config file is bundledo.PersistenceEnabled = true; // default — the offline cacheo.ConfigureDocument<Play>(cfg => cfg.ToCollection("plays")); // default collection = the type name});This registers one singleton exposed as four contracts —
IDocumentStore,IDocumentMaintenance,IObservableDocumentStoreandIChangeFeedDocumentStoreall resolve to the same instance.On iOS, setting
ProjectId+AppIdlets the provider configure Firebase itself; a config file the app has already loaded always wins. -
Register identity (optional)
builder.Services.AddFirebaseIdentity(o => o.ApiKey = "your-web-api-key");
Documents
Section titled “Documents”Each document type maps to its own collection (the type name by default). The document id is the Firestore document id, read from an Id property (cfg.MapIdProperty(...) to override) and matched case-insensitively against the serialized JSON.
class Play{ public string Id { get; set; } = null!; public string Name { get; set; } = null!; public int Version { get; set; }}The id must be set and non-empty on every write — this provider does not generate ids, and an empty one throws. Field names are the JSON property names, so a PropertyNamingPolicy applies to queries automatically.
var store = sp.GetRequiredService<IDocumentStore>();
await store.Insert(new Play { Id = "p1", Name = "Slant Left", Version = 1 });var play = await store.Get<Play>("p1"); // null when absentawait store.Upsert(new Play { Id = "p1", Name = "Slant Right", Version = 2 });await store.Remove<Play>("p1"); // always true — Firestore deletes are idempotentvar cleared = await store.Clear<Play>(); // deletes doc-by-doc, returns the countQuerying
Section titled “Querying”Filters, ordering and limit push down to the native query. Aggregates and the pagination offset are applied client-side over materialized results.
var plays = await store.Query<Play>() .Where(p => p.Version >= 2) .OrderBy(p => p.Version) .ToList();
var count = await store.Query<Play>().Count();var n = await store.Query<Play>().Where(p => p.Version >= 2).ExecuteDelete();await store.Query<Play>().ExecuteUpdate(p => p.Name, "Renamed");Supported: Where, OrderBy, OrderByDescending, Paginate, ToList, ToAsyncEnumerable, Count, Any, ExecuteDelete, ExecuteUpdate, Max, Min, Sum, Average, NotifyOnChange, IgnoreQueryFilters.
Translated operators: ==, !=, <, <=, >, >=, &&, and ICollection.Contains. Anything else throws.
The value being compared against can be a literal, a captured local, a field or property read, a method call, or an inline array — it is evaluated without runtime code generation, so predicates behave the same on a full-AOT build as they do in the debugger.
var cutoff = DateTime.UtcNow.AddDays(-7);var wanted = new[] { "Alpha", "Beta" };
await store.Query<Play>().Where(p => p.Created > cutoff).ToList(); // captured localawait store.Query<Play>().Where(p => wanted.Contains(p.Name)).ToList();await store.Query<Play>().Where(p => new[] { 1, 2 }.Contains(p.Version)).ToList();Count()materializes every matching document — it is not a native aggregate count. Avoid on large collections.Paginate(offset, take)issues a nativeLimit(offset + take)and skips client-side, because Firestore has no offset. Deep pagination reads everything up to the offset.Max/Min/Sum/Averagematerialize and compute in managed code.Selectprojection throws — read withToListand project client-side.
Offline
Section titled “Offline”PersistenceEnabled (default true) turns on the native persistent cache: reads are cache-first, and writes queue locally and drain automatically on reconnect. This is the entire point of the provider — leave it on outside of tests.
Change observation
Section titled “Change observation”await using var sub = await changeFeedStore.SubscribeChanges<Play>((change, ct) =>{ Console.WriteLine($"{change.ChangeType}: {change.Id}"); return Task.CompletedTask;});
await foreach (var change in observableStore.NotifyOnChange<Play>(ct)) { … }
// scoped to a filtered queryawait foreach (var change in store.Query<Play>().Where(p => p.Version > 1).NotifyOnChange(ct)) { … }Both are backed by native snapshot listeners, so changes arrive from any writer. ChangeType is Inserted, Updated or Removed; a Removed change carries the id but no document.
Identity
Section titled “Identity”IFirebaseIdentity signs users in through the Firebase Auth REST API — anonymous and email/password, with automatic token refresh.
var identity = sp.GetRequiredService<IFirebaseIdentity>();
var user = await identity.SignInAnonymouslyAsync();var token = await identity.GetIdTokenAsync(); // refreshes within a minute of expiryvar uid = identity.CurrentUserId;identity.AuthStateChanged += (_, u) => { /* u is null on sign-out */ };Options Reference
Section titled “Options Reference”| Property | Type | Default | Description |
|---|---|---|---|
ProjectId |
string? |
null |
Firebase project id — optional when a config file is bundled |
AppId |
string? |
null |
Firebase application id, paired with ProjectId for explicit init |
ApiKey |
string? |
null |
Firebase Web API key |
PersistenceEnabled |
bool |
true |
The native offline cache |
EmulatorHost |
string? |
null |
host:port of the Firestore emulator |
TypeNameResolution |
TypeNameResolution |
ShortName |
How collection names are derived |
JsonSerializerOptions |
JsonSerializerOptions? |
null |
Drives field names and serialization |
UseReflectionFallback |
bool |
true |
Set false for iOS full-AOT |
Logging |
Action<string>? |
null |
Diagnostic callback |
Per-type mappings, inside a ConfigureDocument<T> block: cfg.ToCollection(...) (or the provider-agnostic cfg.Table = ...), cfg.MapIdProperty(...), cfg.AddQueryFilter(...), cfg.MapVersionProperty(...), cfg.OnBeforeWrite(...) / cfg.OnAfterWrite(...). Store-level: options.MapIdType<TId>(...), options.AddInterceptor(...), options.AddBulkInterceptor(...).
Trimming and AOT
Section titled “Trimming and AOT”The package is built with IsAotCompatible, so the trim and AOT analyzers run over it and it produces no IL warnings of its own. Two pieces are worth knowing about.
Document (de)serialization is the one place that still reaches for reflection, and only when you let it. Pass a JsonTypeInfo<T> — from a JsonSerializerContext — and the typed path is used end to end:
[JsonSerializable(typeof(Play))]public partial class AppJsonContext : JsonSerializerContext;
await store.Insert(play, AppJsonContext.Default.Play);var loaded = await store.Get("p1", AppJsonContext.Default.Play);Set UseReflectionFallback = false to make that mandatory: any call that would have fallen back to reflection throws InvalidOperationException naming the type instead of silently working in the debugger and failing on a trimmed device build.
Firebase Auth (AddFirebaseIdentity) is fully source-generated and needs nothing from you — it has no JsonTypeInfo<T> parameter to thread through, so it carries its own context internally.
Emulator
Section titled “Emulator”var opts = new MobileFirestoreOptions{ ProjectId = "demo-shiny", EmulatorHost = "10.0.2.2:8080", // Android emulator; use "localhost:8080" on the iOS simulator PersistenceEnabled = false // clean slate per run};10.0.2.2 is the Android emulator’s alias for the host machine — localhost will not reach it from there. The iOS simulator shares the host’s network stack, so it uses localhost. The emulator accepts any demo project id and a fake API key. The Auth emulator equivalent is FirebaseIdentityOptions.AuthEmulatorHost.
Limitations
Section titled “Limitations”This provider ships in milestones. Today:
- These throw
NotSupportedException: the stringQuery/QueryStream/Count(whereClause)overloads (use LINQ),BatchInsert,SetProperty,RemoveProperty,GetDiff,ClearAll, andSelectprojection. - Write interceptors do not run.
AddInterceptor,AddBulkInterceptor,cfg.OnBeforeWrite(...)andcfg.OnAfterWrite(...)are accepted by the options but are never invoked. Keep that logic in your calling code. cfg.MapVersionPropertydoes not enforce concurrency. The mapping is recorded, but writes are a plainset()— no version check, andConcurrencyExceptionis never thrown. A stale write silently wins.- No
request.auth.uidin rules yet — see Identity. - No full-text, spatial, vector, temporal, blob or computed-property support. The shared
ConfigureDocument<T>builder offers all of them, so mapping one here is aDocumentConfigurationExceptionwhen the store is built — naming every problem at once, rather than failing on first use. Countand the aggregates materialize documents rather than using native aggregation.IgnoreQueryFilters()restarts the query. It rebuilds from the collection, so anyWhereapplied before it is dropped. Call it first:store.Query<T>().IgnoreQueryFilters().Where(…).
Release notes
Section titled “Release notes”This package ships from the shinyorg/firebase repo and versions independently of DocumentDB itself — its releases are listed here rather than on the DocumentDB releases page.
3.0.0 - TBD
Section titled “3.0.0 - TBD”Requires Shiny.DocumentDb 13.0.0, and per-type configuration is one ConfigureDocument<T> block. The flat per-type methods on MobileFirestoreOptions are removed, matching every other DocumentDB provider — the type is named once and its whole configuration reads top to bottom:
o.ConfigureDocument<Play>(cfg =>{ cfg.ToCollection("plays"); cfg.MapIdProperty(x => x.Id); cfg.AddQueryFilter(p => p.Version >= 1);});MapTypeToCollection<T> → cfg.ToCollection(...), MapIdProperty<T> → cfg.MapIdProperty(...), MapVersionProperty<T> → cfg.MapVersionProperty(...), AddQueryFilter<T> → cfg.AddQueryFilter(...), OnBeforeWrite<T> / OnAfterWrite<T> → cfg.OnBeforeWrite(...) / cfg.OnAfterWrite(...). Store-level members are untouched: MapIdType<TId>, AddInterceptor, AddBulkInterceptor, and every plain property. See Migrating v12 → v13.
A mapping the native SDK cannot honor is now a startup error. MobileFirestoreOptions implements IDocumentStoreOptions and declares its capabilities, so the shared validation pass runs when the store is constructed. The provider-agnostic builder accepts MapTemporal, MapBlob, MapSpatialProperty, MapVectorProperty, MapFullTextProperty and MapComputedProperty on any provider — on device Firestore there is no engine or sidecar behind any of them, and each one is now reported by name in a single DocumentConfigurationException rather than doing nothing.
The collection name ignored TypeNameResolution. The store resolved a type’s collection from typeof(T).Name instead of the store’s resolved document type name, so a store configured with TypeNameResolution.FullName still wrote to the short-name collection — and disagreed with the name every other part of the provider used. It now goes through the same resolver as the rest of the store.
2.0.0 - TBD
Section titled “2.0.0 - TBD”Requires Shiny.DocumentDb 12.0.0. DocumentDB 12 moved the single-document write pipeline onto DocumentProviderBase and added four provider hooks (Mappings, IdCache, ResolveTypeInfo, ResolveDocumentTypeName); this provider implements them and its options now delegate id, query-filter and version mapping to the shared DocumentMappingRegistry. The public surface of MobileFirestoreOptions is unchanged — existing configuration code compiles as-is — but the package will not restore against DocumentDB 11.
Trim and AOT clean. The package is built with IsAotCompatible and produces no IL warnings. Firebase Auth now uses a source-generated JSON context instead of reflection-based PostAsJsonAsync/ReadFromJsonAsync, so sign-in, sign-up and token refresh survive trimming — previously they could fail only on a published device build. Query comparison values are evaluated and serialized without runtime code generation. See Trimming and AOT.
The query builder returns a copy instead of mutating. Where, OrderBy, OrderByDescending and Paginate used to add to the query and return the same instance; they now return a new query, matching every other DocumentDB 12 provider. Two consequences. A builder call used as a bare statement is now discarded — q.Where(x => !x.IsDeleted); on its own line silently loses the clause, so assign the result. Conversely, branching now works: var recent = all.Where(…) no longer disturbs all.
var all = store.Query<Play>();var recent = all.Where(p => p.Version >= 2); // `all` is unchanged — previously both were the same queryAny() permanently capped the query it was called on. The Swift ShinyFirestoreQuery wrapper mutated itself and returned self, so the limitTo(1) that Any() issues stuck to the underlying query — a subsequent ToList() on the same query returned at most one document. The wrapper now returns a new instance from whereField, orderBy and limitTo, preserving Firestore’s own immutable Query semantics. Android was unaffected: its native Query was already immutable.
new[] { … }.Contains(x.Prop) in a Where threw instead of querying. On .NET 10 an inline-array Contains binds to MemoryExtensions.Contains, so the collection reaches the translator wrapped in an array-to-ReadOnlySpan conversion — which the old expression-compiling evaluator could not box, on either platform. Inline arrays now translate to a native in filter. Contains over a captured collection was unaffected.
A predicate that cannot be evaluated without code generation now says so. Where the translator previously relied on Expression.Compile() — which Mono quietly services with its interpreter and NativeAOT cannot — it walks the expression directly. An exotic shape on a build without code generation throws NotSupportedException naming the node type and suggesting you hoist the value into a local, rather than failing at runtime in the native SDK.
1.0.0 - July 21, 2026
Section titled “1.0.0 - July 21, 2026”Initial release, announced alongside DocumentDB 11.2 — built on the provider extension points opened in 11.1.1. See the DocumentDB releases page for the introduction.


