- name
- shiny-firestore-mobile
- description
- Generate code using Shiny.DocumentDb.Firestore.Mobile, an on-device (native) Firebase Firestore provider for Shiny.DocumentDb on iOS and Android — offline-first persistence, real-time snapshot listeners, LINQ queries, and managed Firebase Auth identity.
- auto_invoke
- true
- triggers
- ["firestore mobile","mobile firestore","on-device firestore","native firestore","offline firestore","Shiny.DocumentDb.Firestore.Mobile","MobileFirestoreDocumentStore","MobileFirestoreOptions","AddMobileFirestoreDocumentStore","ConfigureDocument","ToCollection","AddFirebaseIdentity","IFirebaseIdentity","FirebaseRestIdentity","FirebaseIdentityOptions","FirebaseUser","PersistenceEnabled","EmulatorHost","firestore emulator","firestore offline","firestore realtime","firestore snapshot listener","firebase auth maui","firebase anonymous sign in"]
# Shiny Mobile Firestore Provider
## Overview
`Shiny.DocumentDb.Firestore.Mobile` is an **on-device** Firebase Firestore provider for
[Shiny.DocumentDb](https://shinylib.net). It binds the **native** Firebase Firestore SDK (via a first-party
Shiny binding, *not* a third-party one) and adapts it to the `IDocumentStore` contract.
The native SDK owns the hard parts — local cache, offline write queue, real-time listeners, backoff,
conflict handling. This provider is a thin typed adapter over it.
### This is NOT `Shiny.DocumentDb.Firestore`
Do not confuse the two packages. Pick by **where the code runs**:
| | `Shiny.DocumentDb.Firestore` | `Shiny.DocumentDb.Firestore.Mobile` (this skill) |
|---|---|---|
| SDK | `Google.Cloud.Firestore` (admin/gRPC) | Native Firebase Firestore SDK (binding) |
| Runs where | Server / backend host | On the client device |
| Auth | Service account (ADC) | Firebase Auth, per end-user |
| Security rules | Bypassed (admin) | Enforced (`request.auth.uid`) |
| Offline | None | Native persistent cache — offline by default |
| Registration | `AddFirestoreDocumentStore(...)` | `AddMobileFirestoreDocumentStore(...)` |
**Never embed a service-account key in a shipped app** — that is what the server package is for.
## Platform support — read this first
| TFM | Behavior |
|---|---|
| `net10.0-android` | Real adapter over the native SDK. Verified end-to-end against the Firestore emulator. |
| `net10.0-ios` | Real adapter over the native SDK (slim Swift binding). Verified end-to-end against the Firestore emulator. |
| `net10.0` | **Throw-stub.** Every operation throws `PlatformNotSupportedException`. Exists so the surface is unit-testable without a device. |
Both mobile heads are at **feature parity** — the same operations work and the same ones throw, and both pass
the same 9-case emulator harness (CRUD, real-time, query/order/count/delete, identity). The API below applies
identically to Android and iOS. Anything other than these two TFMs throws `PlatformNotSupportedException`, so
multi-targeted app code must guard the mobile paths (`#if ANDROID || IOS`) or avoid resolving the store
elsewhere.
## Installation
```bash
dotnet add package Shiny.DocumentDb.Firestore.Mobile
```
The Android head pulls AndroidX / Guava / DataStore from nuget.org. If the consuming app inherits a
user-level private NuGet source, add a `nuget.config` that clears sources back to nuget.org.
## Setup
### 1. Initialize FirebaseApp
The store resolves Firebase in its constructor and **throws `InvalidOperationException` if Firebase is not
initialized**. Bundle the platform config file — `google-services.json` (Android) / `GoogleService-Info.plist`
(iOS) — for auto-init, or initialize explicitly before the store is resolved. On iOS, setting `ProjectId` +
`AppId` on the options lets the provider configure Firebase itself; a config file already loaded by the app
always wins.
### 2. Register the store
```csharp
using Shiny.DocumentDb;
builder.Services.AddMobileFirestoreDocumentStore(o =>
{
o.ProjectId = "my-project"; // optional when google-services.json is bundled
o.PersistenceEnabled = true; // default — offline cache on
// per-type mapping always goes in a ConfigureDocument<T> block
o.ConfigureDocument<Play>(cfg =>
{
cfg.ToCollection("plays"); // default collection = type name
cfg.MapIdProperty(x => x.Id); // default: a property named Id
cfg.AddQueryFilter(p => p.Version >= 1);
});
});
```
> **Requires `Shiny.DocumentDb` 13.x.** The flat per-type methods (`o.MapTypeToCollection<T>`,
> `o.MapIdProperty<T>`, `o.AddQueryFilter<T>`, `o.MapVersionProperty<T>`, `o.OnBeforeWrite<T>`,
> `o.OnAfterWrite<T>`) were **removed** in 3.0.0 — never generate them. They all live on the
> `ConfigureDocument<T>` builder now. Store-level members (`MapIdType<TId>`, `AddInterceptor`,
> `AddBulkInterceptor`) are unchanged.
This registers one singleton exposed as four contracts — all the **same instance**:
- `IDocumentStore` — CRUD + `Query<T>()`
- `IDocumentMaintenance`
- `IObservableDocumentStore` — `NotifyOnChange<T>()`
- `IChangeFeedDocumentStore` — `SubscribeChanges<T>()`
### 3. Register identity (optional)
```csharp
builder.Services.AddFirebaseIdentity(o => o.ApiKey = "your-web-api-key");
```
## Document requirements
- Each document type maps to its own collection — the type name by default, overridable with
`cfg.ToCollection(name)` (or the provider-agnostic `cfg.Table = name`).
- The document **id is the Firestore document id**. It comes from a property named `Id` by default
(`cfg.MapIdProperty(...)` to override), matched **case-insensitively** against the serialized JSON.
- The id must be present and non-empty on every write — an empty id throws `InvalidOperationException`.
This provider does **not** generate ids for you.
- Documents are stored as native Firestore field maps converted from `System.Text.Json`. **Field names are
the JSON property names** — if you set a `PropertyNamingPolicy`, queries resolve against the converted
name automatically, but hand-written field strings must match the JSON name.
```csharp
class Play
{
public string Id { get; set; } = null!;
public string Name { get; set; } = null!;
public int Version { get; set; }
}
```
## CRUD
```csharp
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 absent
await store.Upsert(new Play { Id = "p1", Name = "Slant Right", Version = 2 });
await store.Remove<Play>("p1"); // always returns true — Firestore delete is idempotent
var cleared = await store.Clear<Play>(); // deletes the collection doc-by-doc, returns the count
```
**`Insert` is an upsert.** `Insert`, `Update`, and `Upsert` all map to native Firestore `set()`, which
overwrites. There is no insert-if-absent semantic yet — `Insert` on an existing id silently replaces it. If a
user needs true insert-if-absent, `Get` first (racy) or wait for the transactional milestone.
## Querying
`Query<T>()` returns an `IDocumentQuery<T>` backed by a native Firestore query. Filters, ordering, and limit
push down to the native query; **aggregates and pagination offset are applied client-side over materialized
results**.
```csharp
var plays = await store
.Query<Play>()
.Where(p => p.Version >= 2)
.OrderBy(p => p.Version)
.ToList();
var count = await store.Query<Play>().Count();
var any = await store.Query<Play>().Where(p => p.Name == "Alpha").Any();
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 predicate operators: `==`, `!=`, `<`, `<=`, `>`, `>=`, `&&`, and `ICollection.Contains`.
Anything else throws.
The compared-against value may be a literal, a captured local, a field/property read, a method call, or an
inline array (`new[] { "a", "b" }.Contains(p.Name)`). It is evaluated by walking the expression, not by
compiling it, so generated predicates behave identically on a full-AOT build.
### Query gotchas
- **Firestore rule: with an inequality filter, the first `OrderBy` must be on the inequality field.**
`.Where(p => p.Version >= 2).OrderBy(p => p.Version)` is valid; ordering by a different field first fails
at the native SDK.
- `Count()` on a query **materializes every document** — it is not a native aggregate. `Count<T>()` on the
store does the same. Avoid on large collections.
- `Paginate(offset, take)` issues a native `Limit(offset + take)` and skips `offset` client-side — Firestore
has no offset. Deep pagination reads everything up to the offset.
- `Max`/`Min`/`Sum`/`Average` materialize and compute in managed code.
- `Select` projection throws `NotSupportedException` — `ToList` and project client-side.
- `IgnoreQueryFilters(params string[])` ignores the names and drops **all** filters (all-or-nothing today),
and it **restarts the query from the collection** — call it before any `Where`, or that `Where` is lost.
- **The builder returns a copy** (as of 2.0.0, matching every other provider). Always assign the result —
`q.Where(…);` on its own line is a no-op. Branching is safe: `var b = a.Where(…)` leaves `a` alone.
- Grouping, cursor paging, vector, and full-text throw.
## Real-time
Both are backed by native snapshot listeners. **The initial snapshot is skipped** — you receive changes from
the point of subscription, not the current contents. Read with `Get`/`ToList` first if you need a baseline.
```csharp
// Callback style — dispose the handle to detach the listener
var sub = await changeFeedStore.SubscribeChanges<Play>((change, ct) =>
{
Console.WriteLine($"{change.ChangeType}: {change.Id} {change.Document?.Name}");
return Task.CompletedTask;
});
await sub.DisposeAsync();
// Stream style — the listener detaches when enumeration ends
await foreach (var change in observableStore.NotifyOnChange<Play>(ct))
Console.WriteLine(change.ChangeType);
// Scoped to a filtered query
await foreach (var change in store.Query<Play>().Where(p => p.Version > 1).NotifyOnChange(ct))
Console.WriteLine(change.Id);
```
`change.ChangeType` is `DocumentChangeType.Inserted` / `Modified` / `Removed`.
## Offline
`PersistenceEnabled` (default `true`) turns on the native persistent cache — reads are cache-first, writes
queue locally and drain automatically on reconnect. This is the entire point of the mobile provider; leave it
on unless a test needs a clean slate.
Because writes queue offline, an `await store.Insert(...)` that completes does **not** guarantee the server
has the write — only that the native SDK accepted it.
## Identity
`IFirebaseIdentity` is a **managed** implementation over the Firebase Auth REST API (no native binding).
```csharp
var identity = sp.GetRequiredService<IFirebaseIdentity>();
var user = await identity.SignInAnonymouslyAsync();
await identity.SignUpWithEmailPasswordAsync("a@b.com", "Passw0rd!");
await identity.SignInWithEmailPasswordAsync("a@b.com", "Passw0rd!");
var token = await identity.GetIdTokenAsync(); // auto-refreshes within 1 min of expiry
var uid = identity.CurrentUserId;
identity.AuthStateChanged += (_, u) => { /* u is null on sign-out */ };
identity.SignOut();
```
`FirebaseUser` is a record: `Uid`, `IdToken`, `RefreshToken`, `ExpiresAt`, `IsAnonymous`, `Email`.
### Critical limitation — the token does not reach the native SDK
The identity service obtains and refreshes the user's token **in managed code only**. It is *not* wired into
the native Firestore SDK's request auth, so **Firestore security rules do not see `request.auth.uid` for
native reads and writes yet**. That integration needs a native `signInWithCustomToken` and is deferred with
the native auth binding.
Until then, **scope per-user data by collection path**:
```csharp
o.ConfigureDocument<Play>(cfg => cfg.ToCollection($"users/{uid}/plays"));
```
Do not tell users their rules are enforced per-user today, and do not generate rules that rely on
`request.auth.uid` for this provider's traffic without flagging this gap.
## Options reference — `MobileFirestoreOptions`
| Member | Notes |
|---|---|
| `ProjectId` / `AppId` / `ApiKey` | Optional when a platform config file is bundled; takes precedence for explicit init |
| `PersistenceEnabled` | Default `true` — native offline cache |
| `EmulatorHost` | `host:port`, points the native SDK at the emulator |
| `TypeNameResolution` | Default `ShortName` |
| `JsonSerializerOptions` | Drives field names and (de)serialization |
| `UseReflectionFallback` | Default `true`; set `false` for iOS full-AOT — see [Trimming and AOT](#trimming-and-aot) |
| `Logging` | `Action<string>` diagnostic callback |
| `MapIdType<TId>(...)` | Custom id CLR types beyond Guid/int/long/string |
| `AddInterceptor` / `AddBulkInterceptor` | **Registered but not invoked today** — see below |
| `ConfigureDocument<T>(cfg => …)` | Everything per-type — the table below |
### Per-type — inside `o.ConfigureDocument<T>(cfg => …)`
| Member | Notes |
|---|---|
| `cfg.ToCollection(name)` | Override the collection (default: type name). `cfg.Table = name` is the same thing |
| `cfg.MapIdProperty(x => x.MyId)` | Override the id property (default: `Id`) |
| `cfg.AddQueryFilter(predicate)` | Global filter, auto-applied to every query; named overload available |
| `cfg.MapVersionProperty(...)` | **Registered but not enforced today** — see below |
| `cfg.OnBeforeWrite(...)` / `cfg.OnAfterWrite(...)` | **Registered but not invoked today** — see below |
The builder is provider-agnostic, so it also offers `MapTemporal`, `MapBlob`, `MapSpatialProperty`,
`MapVectorProperty`, `MapFullTextProperty` and `MapComputedProperty`. **This provider supports none of
them** — mapping one throws `DocumentConfigurationException` when the store is built, listing every
problem at once. Never generate them for a mobile Firestore store.
## Trimming and AOT
The package is `IsAotCompatible` and warning-free. Document (de)serialization is the only reflection seam,
and only when the caller omits a `JsonTypeInfo<T>`. **When generating code for a MAUI app that publishes
trimmed or full-AOT, prefer the `JsonTypeInfo<T>` overloads:**
```csharp
[JsonSerializable(typeof(Play))]
public partial class AppJsonContext : JsonSerializerContext;
Ver no GitHub