<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:fh="http://purl.org/syndication/history/1.0"><channel><title>Shiny.NET | Blog</title><description/><link>https://www.shinylib.net/</link><language>en</language><atom:link rel="self" href="https://www.shinylib.net/blog/rss.xml"/><atom:link rel="prev-archive" href="https://www.shinylib.net/blog/rss/2026-08.xml"/><item><title>Shiny HTTP Transfers: Background Transfers, and Knowing What They&apos;re Doing</title><link>https://www.shinylib.net/blog/2026/08/background-http-transfers/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/08/background-http-transfers/</guid><description>Why is this data still not here? Usually because nothing was carrying it — HttpClient stops the moment the OS suspends your app. Shiny.Net.Http hands the bytes to the platform instead — NSURLSession on iOS, a foreground service on Android, a connectivity-driven managed loop everywhere else, Service Worker Background Sync in the browser — and gives all of them one API. Plus why Azure Blob and S3 get hand-built request signers instead of the vendor SDKs, what the Android foreground service does when the network drops, and how speed, percent and time-remaining are measured consistently on every platform and rendered onto the Lock Screen and the notification shade from one manager.</description><pubDate>Wed, 26 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The question this library exists to answer is not “how do I upload a file”. It is &lt;strong&gt;“why is this data
still not here?”&lt;/strong&gt; — the photo attached twenty minutes ago, the inspection report the office has been
refreshing since lunch, the overnight sync that was meant to be finished before anyone looked.&lt;/p&gt;
&lt;p&gt;The answer is nearly always that nothing was moving. &lt;code dir=&quot;auto&quot;&gt;HttpClient&lt;/code&gt; is a fine way to carry bytes right up
until the user leaves your app, and then it stops. On iOS the process is suspended within seconds of the
app going to the background: threads stop, sockets close. A half-finished 200 MB upload does not pick up
where it left off when the user returns — it starts over, on their cellular plan. Android reclaims a
backgrounded process the moment memory gets tight. The user did nothing wrong; they answered a text
message.&lt;/p&gt;
&lt;p&gt;So the data is not late because something broke. It is late because for most of that window nobody was
carrying it, and no amount of effort inside your process changes that — the OS is doing exactly what it
said it would do. The only thing that helps is not being the one holding the socket.&lt;/p&gt;
&lt;p&gt;This is one of the oldest pieces of code in Shiny — it started life in 2016 as a Xamarin plugin, years
before Shiny existed, and has been carried forward, rewritten and re-platformed ever since. It is also the
module that ends up in the most apps, because sooner or later every app has to move a file at a moment
the user is not watching it happen.&lt;/p&gt;
&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Net.Http&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Net.Http?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Net.Http&quot; alt=&quot;NuGet package Shiny.Net.Http&quot;&gt;&lt;/a&gt;
&lt;p&gt;&lt;strong&gt;Shiny.Net.Http&lt;/strong&gt; hands the transfer to whatever the platform runs on your behalf, and gives you one
API over the lot of them.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddHttpTransfers&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;MyTransferDelegate&lt;/span&gt;&lt;span&gt;&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; transferManager.&lt;/span&gt;&lt;span&gt;Queue&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;HttpTransferRequest&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;receipt-upload&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;https://api.example.com/receipts&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;TransferType.UploadMultipart,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;filePath&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That is the whole thing. Your app can be suspended, backgrounded or terminated a second later and the
bytes keep moving.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-is-actually-carrying-the-bytes&quot;&gt;What is actually carrying the bytes&lt;/h2&gt;&lt;/div&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;iOS / Mac Catalyst&lt;/td&gt;
&lt;td&gt;A background &lt;code dir=&quot;auto&quot;&gt;NSURLSession&lt;/code&gt; — the transfer is owned by the OS daemon, and iOS &lt;strong&gt;relaunches your app&lt;/strong&gt; in the background when it finishes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;A managed &lt;code dir=&quot;auto&quot;&gt;HttpClient&lt;/code&gt; loop inside a foreground service, so the process is not a kill candidate while transfers are pending&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Windows, Linux, macOS, plain .NET&lt;/td&gt;
&lt;td&gt;The same managed loop, gated on &lt;code dir=&quot;auto&quot;&gt;IConnectivity&lt;/code&gt; — every pass re-checks the network and picks transfers back up when it returns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blazor WASM&lt;/td&gt;
&lt;td&gt;Service Worker Background Sync — an IndexedDB queue the SW drains with &lt;code dir=&quot;auto&quot;&gt;fetch()&lt;/code&gt; while the tab is closed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The differences that leak through are the ones that are real, and they are documented rather than
smoothed over with a lie:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;Cancel&lt;/code&gt; deletes the partial file&lt;/strong&gt;; &lt;code dir=&quot;auto&quot;&gt;Pause&lt;/code&gt; does not. Pause leaves the transfer in the queue in
&lt;code dir=&quot;auto&quot;&gt;Paused&lt;/code&gt;, and a user-paused transfer stays paused across relaunch and across connectivity coming back.
It waits for you to call &lt;code dir=&quot;auto&quot;&gt;Resume&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;UseMeteredConnection = false&lt;/code&gt;&lt;/strong&gt; keeps a large transfer on Wi-Fi. The managed loop parks it in
&lt;code dir=&quot;auto&quot;&gt;PausedByCostedNetwork&lt;/code&gt; rather than quietly burning someone’s data.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;State lives in a Shiny &lt;code dir=&quot;auto&quot;&gt;IRepository&lt;/code&gt;, so the queue survives a process restart. That matters more than it
sounds: on iOS the process that finishes your transfer is frequently not the process that started it.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;android-a-foreground-service-and-a-connectivity-gate&quot;&gt;Android: a foreground service, and a connectivity gate&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Android has no transfer daemon to hand the work to. What it does have is the foreground service — the one
contract that says &lt;em&gt;this process is doing something the user knows about, do not reclaim it&lt;/em&gt; — so that is
what the queue runs inside.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;HttpTransferService&lt;/code&gt; starts on the first &lt;code dir=&quot;auto&quot;&gt;Queue()&lt;/code&gt;, and again at app start if the repository still holds
transfers. It promotes itself as &lt;code dir=&quot;auto&quot;&gt;FOREGROUND_SERVICE_TYPE_DATA_SYNC&lt;/code&gt; and hosts the managed loop. When the
queue empties the loop falls out and the service stops itself, so there is no idle notification left
sitting in the shade after the last file lands.&lt;/p&gt;
&lt;p&gt;Inside, every pass is gated on &lt;code dir=&quot;auto&quot;&gt;IConnectivity&lt;/code&gt; before a request is attempted at all:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (connectivity.&lt;/span&gt;&lt;span&gt;IsInternetAvailable&lt;/span&gt;&lt;span&gt;())&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;full&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; connectivity.ConnectionTypes.&lt;/span&gt;&lt;span&gt;HasFlag&lt;/span&gt;&lt;span&gt;(ConnectionTypes.Wifi);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;// ... run each pending transfer&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;No connection means nothing is sent — the pass logs and waits, rather than throwing an
&lt;code dir=&quot;auto&quot;&gt;HttpRequestException&lt;/code&gt; at your delegate for something that is not an error. And when the network dies
&lt;em&gt;mid&lt;/em&gt;-transfer, that is not treated as a failure either:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;catch&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;IOException&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ex&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;PauseTransfer&lt;/span&gt;&lt;span&gt;(transfer, &lt;/span&gt;&lt;span&gt;&quot;Network Disconnected&quot;&lt;/span&gt;&lt;span&gt;, ex);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The transfer moves to &lt;code dir=&quot;auto&quot;&gt;PausedByNoNetwork&lt;/code&gt; and stays in the repository. The next pass with connectivity
picks it straight back up — and because a download re-asks with &lt;code dir=&quot;auto&quot;&gt;Range: bytes=N-&lt;/code&gt; against the bytes
already on disk, walking into a parking garage costs the length of the outage rather than the whole file.&lt;/p&gt;
&lt;p&gt;Four states come out of that gate, and the distinctions between them are the point:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Set by&lt;/th&gt;
&lt;th&gt;Cleared by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;PausedByNoNetwork&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The connection dropped&lt;/td&gt;
&lt;td&gt;Connectivity returning — automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;PausedByCostedNetwork&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Metered network, &lt;code dir=&quot;auto&quot;&gt;UseMeteredConnection = false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wi-Fi returning — automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Paused&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The user&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Resume()&lt;/code&gt;, and nothing else&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Error&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The server said no&lt;/td&gt;
&lt;td&gt;Your delegate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Only the third is sticky. A transfer paused by the network resumes itself; a transfer paused by a person
does not, because a person paused it.&lt;/p&gt;
&lt;p&gt;The hard limits here are Android’s rather than ours. Android 15 caps &lt;code dir=&quot;auto&quot;&gt;dataSync&lt;/code&gt; at a six-hour daily
budget, and &lt;code dir=&quot;auto&quot;&gt;shortService&lt;/code&gt; — opt in with &lt;code dir=&quot;auto&quot;&gt;HttpTransferService.UseShortService&lt;/code&gt;, which skips the
type-specific manifest permission and the Play Store declaration — gets roughly three minutes per
promotion. Both deliver &lt;code dir=&quot;auto&quot;&gt;onTimeout&lt;/code&gt;, and failing to stop promptly is an ANR. So the service stops, logs a
warning, and leaves the queue in the repository; the next &lt;code dir=&quot;auto&quot;&gt;Queue()&lt;/code&gt; or app launch re-arms it. A timeout
defers the work, it does not lose it.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;why-downloads-resume-and-uploads-do-not&quot;&gt;Why downloads resume and uploads do not&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Downloads resume&lt;/strong&gt;, because HTTP standardised the question long before any of us needed it. &lt;code dir=&quot;auto&quot;&gt;Range: bytes=N-&lt;/code&gt; is one header, &lt;code dir=&quot;auto&quot;&gt;206 Partial Content&lt;/code&gt; is one status code, and essentially every static host, CDN
and object store answers both. The managed loop asks, appends when it gets a &lt;code dir=&quot;auto&quot;&gt;206&lt;/code&gt;, and — when a server
ignores the header and sends the whole body back with a &lt;code dir=&quot;auto&quot;&gt;200&lt;/code&gt; — restarts and says so in the log rather
than gluing a second copy of the file onto the first. iOS resumes natively inside the &lt;code dir=&quot;auto&quot;&gt;NSURLSession&lt;/code&gt;
daemon. A 400 MB download that dies at 380 MB in a tunnel therefore costs 20 MB, not 400, and the bytes
already on disk are counted in the total via &lt;code dir=&quot;auto&quot;&gt;Content-Range&lt;/code&gt; so the progress bar comes back where it left
off rather than at zero.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Uploads do not&lt;/strong&gt;, and that is mostly not the client’s decision. Resuming an upload requires the &lt;em&gt;server&lt;/em&gt;
to say how many bytes it already holds and agree to take the rest from there — that is a protocol, not a
header. &lt;a href=&quot;https://tus.io&quot;&gt;tus&lt;/a&gt; specifies exactly that, and it remains rare: it is not in ASP.NET Core out
of the box, nor in Express, FastAPI, Rails or Spring. The object stores come closest, since S3 multipart
and Azure block-blob commits have the primitives, but those are per-vendor APIs rather than anything a
generic transfer layer can assume about &lt;code dir=&quot;auto&quot;&gt;POST /receipts&lt;/code&gt; — and the object-store builders below upload in
a single &lt;code dir=&quot;auto&quot;&gt;PUT&lt;/code&gt; today.&lt;/p&gt;
&lt;p&gt;So &lt;code dir=&quot;auto&quot;&gt;Resume&lt;/code&gt; on an upload means “start over”, on every platform. Writing that down beats an API that reads
nicely and lies in production. When resumable upload endpoints become ordinary, this will use them.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;object-stores-without-the-object-store-sdk&quot;&gt;Object stores, without the object-store SDK&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Sooner or later the destination is a bucket, so there are builders for the two everybody ends up on:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;request&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AzureBlobStorageUploadRequest&lt;/span&gt;&lt;span&gt;(filePath)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WithBlobContainer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;myaccount&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;receipts&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WithSasToken&lt;/span&gt;&lt;span&gt;(sasFromYourApi)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Build&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; transferManager.&lt;/span&gt;&lt;span&gt;Queue&lt;/span&gt;&lt;span&gt;(request);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;request&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AwsS3UploadRequest&lt;/span&gt;&lt;span&gt;(filePath)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WithBucket&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;receipts&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;us-east-1&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WithObjectKey&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;$&quot;2026/08/&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;Guid&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;NewGuid&lt;/span&gt;&lt;span&gt;()}&lt;/span&gt;&lt;span&gt;.pdf&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WithPresignedUrl&lt;/span&gt;&lt;span&gt;(urlFromYourApi)          &lt;/span&gt;&lt;span&gt;// or .WithCredentials(...) to sign on-device&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Build&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Both are pure builders. Each produces an ordinary &lt;code dir=&quot;auto&quot;&gt;HttpTransferRequest&lt;/code&gt; — a &lt;code dir=&quot;auto&quot;&gt;PUT&lt;/code&gt;, a URI and a header
dictionary — which then goes through the same queue, the same repository, the same &lt;code dir=&quot;auto&quot;&gt;NSURLSession&lt;/code&gt; or
foreground service and the same Live Activity as any other transfer. Neither is a second transfer path.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;why-not-azurestorageblobs-or-awssdks3&quot;&gt;Why not &lt;code dir=&quot;auto&quot;&gt;Azure.Storage.Blobs&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;AWSSDK.S3&lt;/code&gt;&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;They are good SDKs. They are also built on an assumption that is false on a phone: &lt;strong&gt;that your process is
still running.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;An SDK upload owns its own &lt;code dir=&quot;auto&quot;&gt;HttpClient&lt;/code&gt; and its own socket, inside your app, which is precisely the
arrangement this library exists to get out of. &lt;code dir=&quot;auto&quot;&gt;BlobClient.UploadAsync&lt;/code&gt; is an &lt;code dir=&quot;auto&quot;&gt;await&lt;/code&gt; in your process, and
iOS suspends your process — so it dies along with everything else the moment the user switches apps, and
there is no seam where a half-finished SDK upload could be handed to &lt;code dir=&quot;auto&quot;&gt;NSURLSession&lt;/code&gt; instead. The SDK’s
retry policy is no help; it stopped running too. You cannot background the thing you are carrying
yourself.&lt;/p&gt;
&lt;p&gt;The credential model is the second problem, and it is the one with consequences. Both SDKs are shaped
around a client that holds an account key or IAM credentials. An app bundle is not a secret — a storage
account key shipped inside one has been handed to every user who installs it, and it usually grants far
more than “write this one file”. The mobile shape is the inverse: your backend mints a SAS token or a
presigned URL, scoped to one blob and expiring shortly, and the device never sees a long-lived
credential. At which point the upload is a &lt;code dir=&quot;auto&quot;&gt;PUT&lt;/code&gt; to a URL with a couple of headers, and there is nothing
left for an SDK to do.&lt;/p&gt;
&lt;p&gt;What remains is a wire protocol, and the wire protocol is small — which is why the builders &lt;em&gt;are&lt;/em&gt; the
whole implementation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Azure&lt;/strong&gt; wants &lt;code dir=&quot;auto&quot;&gt;x-ms-blob-type: BlockBlob&lt;/code&gt;, a &lt;code dir=&quot;auto&quot;&gt;Content-Length&lt;/code&gt;, a &lt;code dir=&quot;auto&quot;&gt;Content-Disposition&lt;/code&gt;, and either a
SAS query string or a shared-key &lt;code dir=&quot;auto&quot;&gt;Authorization&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;S3&lt;/strong&gt; wants Signature V4 — an HMAC-SHA256 over a canonical request, off a key derived in four chained
steps — which is a page of &lt;code dir=&quot;auto&quot;&gt;System.Security.Cryptography&lt;/code&gt; and no dependencies at all.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The S3 signer takes one deliberate shortcut worth knowing about. It signs with
&lt;code dir=&quot;auto&quot;&gt;x-amz-content-sha256: UNSIGNED-PAYLOAD&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// S3 allows UNSIGNED-PAYLOAD so we don&apos;t need to hash potentially large files&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;payloadHash&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;UNSIGNED-PAYLOAD&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A literal SigV4 signature covers the SHA-256 of the body, which for a 400 MB video means reading the
entire file end to end, on battery, before a single byte leaves the device — and then reading it again to
send it. S3 explicitly permits &lt;code dir=&quot;auto&quot;&gt;UNSIGNED-PAYLOAD&lt;/code&gt; over HTTPS, so the hash is skipped and the transfer
starts immediately.&lt;/p&gt;
&lt;p&gt;The rest is size and trimming. iOS builds trim aggressively, the vendor SDKs bring wide dependency graphs
and reflective serialisation with them, and none of that is much fun to keep working for the sake of a
file &lt;code dir=&quot;auto&quot;&gt;PUT&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-part-to-watch-a-signature-has-a-clock&quot;&gt;The part to watch: a signature has a clock&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;A SigV4 header signature is stamped with &lt;code dir=&quot;auto&quot;&gt;x-amz-date&lt;/code&gt; at &lt;code dir=&quot;auto&quot;&gt;Build()&lt;/code&gt; time, and AWS rejects it outside
roughly a fifteen-minute skew window. A background transfer, by design, may sit in the queue for hours —
waiting for Wi-Fi, waiting for the network, waiting for a foreground-service window. Those two facts do
not get along, and the result is a &lt;code dir=&quot;auto&quot;&gt;403&lt;/code&gt; in your &lt;code dir=&quot;auto&quot;&gt;OnError&lt;/code&gt; rather than anything mysterious.&lt;/p&gt;
&lt;p&gt;So for anything that might wait, mint a &lt;strong&gt;presigned URL or SAS on the server with an expiry chosen for
the wait&lt;/strong&gt; — long enough to cover an overnight queue. &lt;code dir=&quot;auto&quot;&gt;WithCredentials&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;WithSharedKeyAuthorization&lt;/code&gt;
are for transfers you expect to run promptly, and for desktop or server processes, where credentials
belong in the first place.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;why-not-a-background-job&quot;&gt;Why not a background job?&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Fair question — Shiny ships a perfectly good job scheduler. Use it for work; don’t use it for blobs.&lt;/p&gt;
&lt;p&gt;A job is a &lt;strong&gt;time window&lt;/strong&gt;. A background transfer is a &lt;strong&gt;handoff&lt;/strong&gt;. That distinction is the whole answer,
and on iOS it is not subtle.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.Jobs&lt;/code&gt; on iOS is a &lt;code dir=&quot;auto&quot;&gt;BGProcessingTaskRequest&lt;/code&gt; submitted to &lt;code dir=&quot;auto&quot;&gt;BGTaskScheduler&lt;/code&gt;. iOS decides when the
window opens — usually when the device is idle, often overnight, throttled by how often the user actually
launches your app — and iOS decides when it closes. When it closes, &lt;code dir=&quot;auto&quot;&gt;ExpirationHandler&lt;/code&gt; fires, the
&lt;code dir=&quot;auto&quot;&gt;CancellationToken&lt;/code&gt; handed to your job cancels, and the stream you were copying stops wherever it happened
to be. A 200 MB upload does not fit inside a discretionary window that the OS is entitled to revoke, and
the user who tapped &lt;em&gt;Send&lt;/em&gt; thirty seconds ago is not expecting it to go tonight while they sleep.&lt;/p&gt;
&lt;p&gt;A background &lt;code dir=&quot;auto&quot;&gt;NSURLSession&lt;/code&gt; has no window. The daemon owns the socket, keeps moving bytes while your app is
suspended, keeps moving them after your app is terminated, and iOS &lt;strong&gt;relaunches your process in the
background&lt;/strong&gt; to hand you the result. Nothing relaunches a terminated app to run a job.&lt;/p&gt;
&lt;p&gt;Downloads reach the same conclusion from the other direction. Expire a job 380 MB into a 400 MB file and
there is nothing usable to hand back unless the job itself wrote the &lt;code dir=&quot;auto&quot;&gt;Range&lt;/code&gt; bookkeeping and persisted the
byte counts — at which point it has become a transfer layer, only without the daemon that would have kept
it moving through the suspension. Handed over instead, the resume, the partial file and the persisted
counts are simply there for whichever process picks the transfer up next.&lt;/p&gt;
&lt;p&gt;Android differs in the mechanics and arrives in the same place. Jobs there are a WorkManager
&lt;code dir=&quot;auto&quot;&gt;PeriodicWorkRequest&lt;/code&gt; — a fifteen-minute floor on the period and a worker that gets stopped at around ten
minutes of execution. A large file on a poor connection will not reliably finish inside that, and starting
over every fifteen minutes is not progress. Transfers run under a foreground service instead, which is the
contract Android actually offers for &lt;em&gt;this will take a while and the user knows about it&lt;/em&gt;.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Jobs&lt;/th&gt;
&lt;th&gt;Transfers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Starts&lt;/td&gt;
&lt;td&gt;When the OS feels like it — discretionary, usage-throttled&lt;/td&gt;
&lt;td&gt;Immediately&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time limit&lt;/td&gt;
&lt;td&gt;iOS expiration handler; ~10 min WorkManager worker&lt;/td&gt;
&lt;td&gt;None imposed on the transfer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Survives app termination&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes — iOS relaunches your app to deliver the result&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resume&lt;/td&gt;
&lt;td&gt;Whatever you write yourself&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Range&lt;/code&gt; requests, or native on iOS; byte counts persisted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Progress to the user&lt;/td&gt;
&lt;td&gt;Nothing to show&lt;/td&gt;
&lt;td&gt;Live Activity / foreground notification&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Where jobs are genuinely right is &lt;em&gt;around&lt;/em&gt; the transfer rather than instead of it: reconciling state after
the fact, cleaning up finished files, deciding what to queue next. Queue the blob and let the platform
carry it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;metrics&quot;&gt;Metrics&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Once a transfer is out of your process, “is it working?” stops being a rhetorical question. Every update
carries a &lt;code dir=&quot;auto&quot;&gt;TransferProgress&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;manager.UpdateReceived &lt;/span&gt;&lt;span&gt;+=&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;_&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;result&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; result.Progress;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Console.&lt;/span&gt;&lt;span&gt;WriteLine&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;$&quot;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;PercentComplete&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt;P0&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; · &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;BytesPerSecond&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; B/s · &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;EstimatedTimeRemaining&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; left&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;};&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;How it is arrived at&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;BytesTransferred&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;BytesToTransfer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bytes written so far, and the total — from &lt;code dir=&quot;auto&quot;&gt;Content-Length&lt;/code&gt;, or from &lt;code dir=&quot;auto&quot;&gt;Content-Range&lt;/code&gt; on a resumed download so the total counts the part already on disk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;IsDeterministic&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whether a total is known at all. A chunked response has no &lt;code dir=&quot;auto&quot;&gt;Content-Length&lt;/code&gt;, and a progress bar that invents one is worse than an indeterminate one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;BytesPerSecond&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sampled, not averaged over the whole transfer — see below&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;PercentComplete&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;transferred / total&lt;/code&gt;, or &lt;code dir=&quot;auto&quot;&gt;-1&lt;/code&gt; when the total is unknown. Not &lt;code dir=&quot;auto&quot;&gt;0&lt;/code&gt; — a caller must be able to tell “nothing yet” from “no idea”&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;EstimatedTimeRemaining&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;bytesRemaining / BytesPerSecond&lt;/code&gt;, and &lt;code dir=&quot;auto&quot;&gt;TimeSpan.Zero&lt;/code&gt; when there is no honest answer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div&gt;&lt;h3 id=&quot;throughput-is-sampled-on-a-rolling-window&quot;&gt;Throughput is sampled on a rolling window&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The managed loop counts bytes into an accumulator and only publishes when the stopwatch passes two
seconds, then divides and resets both:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;else&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (stop.Elapsed.TotalSeconds &lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;bps&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; Convert.&lt;/span&gt;&lt;span&gt;ToInt64&lt;/span&gt;&lt;span&gt;(totalSince &lt;/span&gt;&lt;span&gt;/&lt;/span&gt;&lt;span&gt; stop.Elapsed.TotalSeconds);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;PublishProgress&lt;/span&gt;&lt;span&gt;(transfer, &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TransferProgress&lt;/span&gt;&lt;span&gt;(bps, totalBytes, totalBytesXfer));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;totalSince &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;stop.&lt;/span&gt;&lt;span&gt;Restart&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Two things fall out of that. The rate is &lt;em&gt;current&lt;/em&gt; throughput rather than a cumulative average, so a
transfer that recovers from a slow patch reports the recovery instead of dragging its history along.
And the update rate is bounded at the source — an 8 KB read loop on a fast connection would otherwise
fire thousands of events a second at your UI thread.&lt;/p&gt;
&lt;p&gt;On iOS and Mac Catalyst the number comes from the platform instead — &lt;code dir=&quot;auto&quot;&gt;NSProgress.Throughput&lt;/code&gt; on the
task, in &lt;code dir=&quot;auto&quot;&gt;DidSendBodyData&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;DidWriteData&lt;/code&gt; — because the daemon moving the bytes is in a better position
to measure them than we are.&lt;/p&gt;
&lt;p&gt;Every progress tick also writes byte counts back to the repository, which is what lets the app relaunch
mid-transfer and show a bar that is already in the right place. A late tick for a transfer that has since
been paused or removed is dropped rather than resurrecting it.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;three-ways-to-consume-it&quot;&gt;Three ways to consume it&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// 1. the firehose — every transfer, every tick (remember to -= it)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;manager.UpdateReceived &lt;/span&gt;&lt;span&gt;+=&lt;/span&gt;&lt;span&gt; handler;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// 2. one transfer, awaited to a terminal state&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;result&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; manager.&lt;/span&gt;&lt;span&gt;WatchTransfer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;receipt-upload&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// 3. a bindable collection for UI&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; monitor.&lt;/span&gt;&lt;span&gt;Start&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;syncContext&lt;/span&gt;&lt;span&gt;: SynchronizationContext.Current);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// monitor.Transfers -&gt; HttpTransferObject : INotifyPropertyChanged&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;HttpTransferMonitor&lt;/code&gt; seeds itself from the repository, follows repository adds and removes as well as
progress ticks, marshals to a &lt;code dir=&quot;auto&quot;&gt;SynchronizationContext&lt;/code&gt; if you hand it one, and can evict completed,
cancelled or errored rows on its own. It is the one to reach for when a screen needs to show what is in
flight; the raw event is for everything else.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-metrics-the-user-sees&quot;&gt;The metrics the user sees&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The user is not in your app — that is the entire premise — so the numbers have to land somewhere they
can see: an iOS &lt;strong&gt;Live Activity&lt;/strong&gt; on the Lock Screen and in the Dynamic Island, or the Android
foreground-service notification, promoted on Android 16 to a live update with a status bar chip.&lt;/p&gt;
&lt;p&gt;One call, and nothing in your transfer delegate:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddTransferProgress&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;opts&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;opts.Scope       &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; TransferProgressScope.Summary;   &lt;/span&gt;&lt;span&gt;// one surface for a batch, or PerTransfer&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;opts.Fields      &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; TransferProgressFields.Default;  &lt;/span&gt;&lt;span&gt;// file, direction, %, bytes, speed, ETA&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;opts.ShortStatus &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; TransferProgressShortStatus.Percent;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;TransferProgressManager&lt;/code&gt; is deliberately one class for every platform. It subscribes at startup — not
on first use, because on iOS the surface has to be moved to its final state by a process that was
relaunched in the background — coalesces the firehose to one update a second and one percent of
movement, aggregates a batch into a single figure (finished transfers stay in the aggregate so the bar
never walks backwards when one of five completes), and starts, updates and retires the surface. An
&lt;code dir=&quot;auto&quot;&gt;ITransferProgressRenderer&lt;/code&gt; owns nothing but the drawing. The aggregation and lifetime rules are exactly
the things that would silently diverge if each platform kept its own copy.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Fields&lt;/code&gt; is a flags enum over the human-readable text, so switching off &lt;code dir=&quot;auto&quot;&gt;Speed&lt;/code&gt; removes it from the Lock
Screen without touching any Swift. The raw culture-invariant values (&lt;code dir=&quot;auto&quot;&gt;bps&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;percent&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;etaSeconds&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;bytes&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;total&lt;/code&gt;, …) ride along in &lt;code dir=&quot;auto&quot;&gt;TransferProgressContent.Data&lt;/code&gt; for a custom widget to format itself,
and &lt;code dir=&quot;auto&quot;&gt;TransferProgressContentBuilder.FormatBytes&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;FormatRate&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;FormatDuration&lt;/code&gt; are public statics you
can reuse in ordinary in-app UI so the numbers read the same everywhere.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-measurement-problem-ios-hands-you&quot;&gt;The measurement problem iOS hands you&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;A background &lt;code dir=&quot;auto&quot;&gt;NSURLSession&lt;/code&gt; delivers &lt;strong&gt;no&lt;/strong&gt; progress callbacks while your app is suspended.
&lt;code dir=&quot;auto&quot;&gt;DidWriteData&lt;/code&gt; stops firing; iOS wakes you when the transfer completes. A fraction-based bar therefore
sits frozen for most of a long transfer, which reads as “broken” rather than “backgrounded”.&lt;/p&gt;
&lt;p&gt;So progress is emitted as a &lt;strong&gt;time range&lt;/strong&gt; the system animates by itself, anchored in the &lt;em&gt;past&lt;/em&gt; — at the
point a constant-rate transfer would have started — so the bar already sits at the true fraction and
keeps moving without further updates. Anchoring at “now” would snap it back to zero on every tick. Each
real callback re-anchors it, and it falls back to a plain fraction when the transfer is stalled, paused,
of unknown size, or when the projection exceeds an hour and stops being a measurement. Android resolves
the range straight back to a fraction, since its foreground service is alive the whole time and real
numbers keep arriving.&lt;/p&gt;
&lt;p&gt;For uploads there is an exact answer available: your server knows how many bytes actually landed. Turn on
&lt;code dir=&quot;auto&quot;&gt;RequestPushToken&lt;/code&gt; and it can push byte-accurate progress through the entire suspended window. It buys
nothing for downloads — no server knows how far the device got.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;where-to-go-next&quot;&gt;Where to go next&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The &lt;a href=&quot;https://www.shinylib.net/httptransfers/&quot;&gt;HTTP Transfers docs&lt;/a&gt; cover the delegate and retry model, &lt;a href=&quot;https://www.shinylib.net/httptransfers/monitoring/&quot;&gt;monitoring&lt;/a&gt;,
&lt;a href=&quot;https://www.shinylib.net/httptransfers/progress/&quot;&gt;progress surfaces&lt;/a&gt;, and the &lt;a href=&quot;https://www.shinylib.net/httptransfers/azure/&quot;&gt;Azure&lt;/a&gt; and
&lt;a href=&quot;https://www.shinylib.net/httptransfers/aws-s3/&quot;&gt;S3&lt;/a&gt; builders.&lt;/p&gt;</content:encoded><category>httptransfers</category><category>Feature</category><category>MAUI</category><category>iOS</category><category>Android</category></item><item><title>DocumentDB in Aspire</title><link>https://www.shinylib.net/blog/2026/08/documentdb-aspire/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/08/documentdb-aspire/</guid><description>Which database backs your document store, and how it gets seeded, belong in the AppHost — not compiled into your service. The Aspire integration makes the consuming code one provider-agnostic line with health checks and OpenTelemetry attached, models the admin UI as a resource, and points an entire Orleans silo at the same store.</description><pubDate>Mon, 24 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.Aspire.Hosting&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.Aspire.Hosting?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.DocumentDb.Aspire.Hosting&quot; alt=&quot;NuGet package Shiny.DocumentDb.Aspire.Hosting&quot;&gt;&lt;/a&gt;
&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.Aspire.Client&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.Aspire.Client?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.DocumentDb.Aspire.Client&quot; alt=&quot;NuGet package Shiny.DocumentDb.Aspire.Client&quot;&gt;&lt;/a&gt;
&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.Aspire.Orleans&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.Aspire.Orleans?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.DocumentDb.Aspire.Orleans&quot; alt=&quot;NuGet package Shiny.DocumentDb.Aspire.Orleans&quot;&gt;&lt;/a&gt;
&lt;p&gt;DocumentDb has always been able to run on ten-odd backends. The awkward part was that &lt;em&gt;choosing&lt;/em&gt; one is a
line of C# inside the service:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;options.DatabaseProvider &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PostgreSqlDatabaseProvider&lt;/span&gt;&lt;span&gt;(connectionString);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Which is fine until dev wants SQLite, CI wants a throwaway container, and production wants managed
PostgreSQL — and now the thing that differs between environments is compiled into your app. That is a
deployment decision wearing a source-code costume.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://www.shinylib.net/documentdb/aspire&quot;&gt;Aspire integration&lt;/a&gt; moves it where it belongs. The AppHost picks the backend and
gates the seeding; the consuming service gets one provider-agnostic line, with a health check and
OpenTelemetry already wired.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-apphost-picks-the-backend&quot;&gt;The AppHost picks the backend&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; builder&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddPostgresDocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orders&quot;&lt;/span&gt;&lt;span&gt;)     &lt;/span&gt;&lt;span&gt;// provisions Postgres and models the store&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WithSeeder&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;ct&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;// Runs once, after the DB is ready, before dependents start.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;// ctx =&gt; (StoreName, Provider, ConnectionString)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.&lt;/span&gt;&lt;span&gt;AddProject&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Projects&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Api&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;&quot;api&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WithReference&lt;/span&gt;&lt;span&gt;(store);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Swapping the backend is one call, and the consuming service is untouched:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.&lt;/span&gt;&lt;span&gt;AddSqliteDocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orders&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;orders.db&quot;&lt;/span&gt;&lt;span&gt;);   &lt;/span&gt;&lt;span&gt;// local dev — no container at all&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.&lt;/span&gt;&lt;span&gt;AddSqlServerDocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orders&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.&lt;/span&gt;&lt;span&gt;AddMySqlDocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orders&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;If you already model the database resource yourself, wrap it — the provider is auto-detected, or you name
it explicitly:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;pg&lt;/span&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; builder.&lt;/span&gt;&lt;span&gt;AddPostgres&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orders-server&quot;&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;AddDatabase&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orders-db&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; pg.&lt;/span&gt;&lt;span&gt;AsDocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orders&quot;&lt;/span&gt;&lt;span&gt;);    &lt;/span&gt;&lt;span&gt;// name MUST differ from the DB resource name&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;CockroachDB and MariaDB have no first-party Aspire hosting resource and can’t be auto-detected (a plain
&lt;code dir=&quot;auto&quot;&gt;AddPostgres&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;AddMySql&lt;/code&gt; resource detects as Postgres / MySql), so model the container yourself and pass
&lt;code dir=&quot;auto&quot;&gt;DocumentProviderKind.CockroachDb&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;MariaDb&lt;/code&gt; to select the wire-compatible variant.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-service-gets-one-line&quot;&gt;The service gets one line&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Api Program.cs&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.&lt;/span&gt;&lt;span&gt;AddDocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orders&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;configureOptions&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.&lt;/span&gt;&lt;span&gt;ConfigureDocument&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;cfg&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; cfg.Table &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; cfg.TypeName));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The store is registered &lt;strong&gt;keyed&lt;/strong&gt; by name, so resolve it with &lt;code dir=&quot;auto&quot;&gt;[FromKeyedServices]&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;OrdersService&lt;/span&gt;&lt;span&gt;([&lt;/span&gt;&lt;span&gt;FromKeyedServices&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orders&quot;&lt;/span&gt;&lt;span&gt;)] &lt;/span&gt;&lt;span&gt;IDocumentStore&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;?&gt; &lt;/span&gt;&lt;span&gt;Get&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Get&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(id);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Nothing in that service knows which database it’s talking to, and it isn’t a leaky abstraction that stops
working the moment you need something real — &lt;code dir=&quot;auto&quot;&gt;configureOptions&lt;/code&gt; is the full &lt;code dir=&quot;auto&quot;&gt;DocumentStoreOptions&lt;/code&gt; surface.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-contract-underneath&quot;&gt;The contract underneath&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;There’s no magic here, and it’s worth knowing the shape because you can drive it by hand if you need to.
The hosting resource implements &lt;code dir=&quot;auto&quot;&gt;IResourceWithConnectionString&lt;/code&gt; over its backing database, and &lt;code dir=&quot;auto&quot;&gt;WithReference&lt;/code&gt;
publishes &lt;strong&gt;two&lt;/strong&gt; things to the consumer:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;ConnectionStrings:orders&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the backing database’s connection string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny:DocumentDb:orders:Provider&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the &lt;code dir=&quot;auto&quot;&gt;DocumentProviderKind&lt;/code&gt; name&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The client reads both, maps the kind to an &lt;code dir=&quot;auto&quot;&gt;IDatabaseProvider&lt;/code&gt;, and registers the keyed store. A connection
string with no matching provider key is ignored — so a Redis or blob reference in the same AppHost doesn’t
turn into a junk store.&lt;/p&gt;
&lt;p&gt;And because the client owns the registration, it also attaches the things you’d otherwise wire by hand: a
&lt;code dir=&quot;auto&quot;&gt;SELECT 1&lt;/code&gt; health check, plus the &lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb&lt;/code&gt; meter and &lt;code dir=&quot;auto&quot;&gt;ActivitySource&lt;/code&gt; into OpenTelemetry, so query
metrics and trace spans land in the Aspire dashboard with no further work.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;settings&quot;&gt;Settings&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.&lt;/span&gt;&lt;span&gt;AddDocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orders&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;settings&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;settings.MultiTenant &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;settings.DisableHealthChecks &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;ConnectionString&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;Provider&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Override what the AppHost injected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;DisableHealthChecks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Skips the &lt;code dir=&quot;auto&quot;&gt;SELECT 1&lt;/code&gt; probe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;DisableTracing&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;DisableMetrics&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Skips the OpenTelemetry wiring&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;MultiTenant&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shared-table multi-tenancy — adds a &lt;code dir=&quot;auto&quot;&gt;TenantId&lt;/code&gt; column, filters every query by the current tenant, resolved from a registered &lt;code dir=&quot;auto&quot;&gt;ITenantResolver&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;PortableSpatial&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Forces the &lt;a href=&quot;https://www.shinylib.net/documentdb/spatial#portable-vs-native-spatial&quot;&gt;dependency-free spatial tier&lt;/a&gt; — no PostGIS, no native geometry column. For when you can’t install the backend’s spatial extension&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;When configuration depends on other registered services — an interceptor with its own dependencies, say —
use &lt;code dir=&quot;auto&quot;&gt;configureServiceOptions&lt;/code&gt;, which runs with the resolved &lt;code dir=&quot;auto&quot;&gt;IServiceProvider&lt;/code&gt; when the keyed store is first
created:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.&lt;/span&gt;&lt;span&gt;AddDocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;orders&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;configureServiceOptions&lt;/span&gt;&lt;span&gt;: (&lt;/span&gt;&lt;span&gt;sp&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.&lt;/span&gt;&lt;span&gt;AddInterceptor&lt;/span&gt;&lt;span&gt;(sp.&lt;/span&gt;&lt;span&gt;GetRequiredService&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;AuditInterceptor&lt;/span&gt;&lt;span&gt;&gt;()));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;typed-documentcontext-on-an-aspire-resource&quot;&gt;Typed &lt;code dir=&quot;auto&quot;&gt;DocumentContext&lt;/code&gt; on an Aspire resource&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;If you use the source-generated &lt;a href=&quot;https://www.shinylib.net/documentdb/context&quot;&gt;typed context&lt;/a&gt;, &lt;code dir=&quot;auto&quot;&gt;AddDocumentContextProvider&lt;/code&gt; returns
the &lt;code dir=&quot;auto&quot;&gt;Action&amp;#x3C;DocumentStoreOptions&gt;&lt;/code&gt; the generated &lt;code dir=&quot;auto&quot;&gt;Add{Context}&lt;/code&gt; method takes — same injected connection
string, same health check and telemetry, same settings:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddOrdersContext&lt;/span&gt;&lt;span&gt;(builder.&lt;/span&gt;&lt;span&gt;AddDocumentContextProvider&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orders&quot;&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddInvoicesContext&lt;/span&gt;&lt;span&gt;(builder.&lt;/span&gt;&lt;span&gt;AddDocumentContextProvider&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;invoices&quot;&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Each context registers its store keyed by the &lt;strong&gt;context type&lt;/strong&gt;, so several contexts backed by different
Aspire resources coexist without shadowing each other. That’s the multi-store story, and it’s two lines.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;an-entire-orleans-silo-pointed-at-the-same-store&quot;&gt;An entire Orleans silo, pointed at the same store&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;a href=&quot;https://www.shinylib.net/blog/2026/08/documentdb-orleans-stack/&quot;&gt;The previous post&lt;/a&gt; covered running the whole Orleans persistence
stack on &lt;code dir=&quot;auto&quot;&gt;IDocumentStore&lt;/code&gt; — membership, grain storage, reminders, grain directory and streams. Under Aspire,
pointing all of it at the provisioned store is one line:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Silo Program.cs&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.&lt;/span&gt;&lt;span&gt;AddDocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orleans&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.&lt;/span&gt;&lt;span&gt;UseOrleans&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;silo&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; silo.&lt;/span&gt;&lt;span&gt;UseAspireDocumentDb&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orleans&quot;&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;UseAspireDocumentDb&lt;/code&gt; wires each Orleans provider’s &lt;code dir=&quot;auto&quot;&gt;StoreFactory&lt;/code&gt; to resolve the keyed &lt;code dir=&quot;auto&quot;&gt;IDocumentStore&lt;/code&gt;, so
the silo’s persistence shares the one Aspire-managed store — its connection, its health check, its telemetry.
On the AppHost the silo is just another consumer:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; builder.&lt;/span&gt;&lt;span&gt;AddPostgresDocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orleans&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.&lt;/span&gt;&lt;span&gt;AddProject&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Projects&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Silo&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;&quot;silo&quot;&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;WithReference&lt;/span&gt;&lt;span&gt;(store);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Because DocumentDb is schema-free there are &lt;strong&gt;no setup scripts&lt;/strong&gt; — the membership and storage tables are
created on demand. Take a subset with the feature flags if you don’t want all four:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;silo.&lt;/span&gt;&lt;span&gt;UseAspireDocumentDb&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;orleans&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;DocumentDbOrleansFeatures.GrainStorage &lt;/span&gt;&lt;span&gt;|&lt;/span&gt;&lt;span&gt; DocumentDbOrleansFeatures.Reminders);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Streams are the one piece that is &lt;strong&gt;not&lt;/strong&gt; in &lt;code dir=&quot;auto&quot;&gt;All&lt;/code&gt;, and that’s deliberate rather than an oversight:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;silo.&lt;/span&gt;&lt;span&gt;UseAspireDocumentDb&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orleans&quot;&lt;/span&gt;&lt;span&gt;, DocumentDbOrleansFeatures.All &lt;/span&gt;&lt;span&gt;|&lt;/span&gt;&lt;span&gt; DocumentDbOrleansFeatures.Streams);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Streams need a backend with row-level pessimistic locking, and the silo refuses to start without it. Folding
that into &lt;code dir=&quot;auto&quot;&gt;All&lt;/code&gt; would turn a working SQLite or DuckDB Aspire app into one that won’t boot, purely because it
took a package update. &lt;code dir=&quot;auto&quot;&gt;configureStreams&lt;/code&gt; is for tuning only — the store always comes from Aspire.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-admin-ui-as-a-resource&quot;&gt;The admin UI as a resource&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Aspire.Hosting&lt;/code&gt; also models &lt;a href=&quot;https://www.shinylib.net/documentdb/admin/&quot;&gt;ShinyDocDbMyAdmin&lt;/a&gt; as a resource, so it
comes up with the rest of your app and every store you reference is already connected — no connection
strings pasted in by hand:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; builder.&lt;/span&gt;&lt;span&gt;AddPostgresDocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orders&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.&lt;/span&gt;&lt;span&gt;AddDocumentDbAdmin&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;port&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;8085&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WithReference&lt;/span&gt;&lt;span&gt;(store)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WaitFor&lt;/span&gt;&lt;span&gt;(store);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;WithReference&lt;/code&gt; is the same contract a consuming service uses, so nothing special was needed to make this
work. Referenced stores appear under a &lt;strong&gt;from host&lt;/strong&gt; badge and can’t be edited or deleted from the UI —
they’re declared in the AppHost, so that’s where they change. &lt;code dir=&quot;auto&quot;&gt;WithDataVolume()&lt;/code&gt; keeps saved connections and
queries across runs, and the image tag defaults to the hosting package’s own version, so upgrading the
integration brings the matching UI with it.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;or-the-terminal-one-no-container&quot;&gt;Or the terminal one, no container&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;New in &lt;strong&gt;13.4&lt;/strong&gt;: Aspire 13.5 added interactive terminal sessions, which was the missing piece for modelling
the &lt;a href=&quot;https://www.shinylib.net/documentdb/admin/terminal/#aspire-apphost&quot;&gt;terminal front end&lt;/a&gt; as a &lt;em&gt;process&lt;/em&gt; rather than a container:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;#&lt;/span&gt;&lt;span&gt;pragma&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;warning&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;disable&lt;/span&gt;&lt;span&gt; ASPIRETERMINAL001&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.&lt;/span&gt;&lt;span&gt;AddDocumentDbAdminTerminal&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WithReference&lt;/span&gt;&lt;span&gt;(store)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WithStartupProfile&lt;/span&gt;&lt;span&gt;(store)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WaitFor&lt;/span&gt;&lt;span&gt;(store);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;aspire&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;config&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;set&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;features.terminalCommandsEnabled&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;aspire&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;terminal&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;attach&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;documentdb-terminal&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;WithStartupProfile&lt;/code&gt; passes &lt;code dir=&quot;auto&quot;&gt;--profile&lt;/code&gt;, so attaching lands you on that database rather than the connection
list. Locally this replaces the container outright, and it’s the only one of the two that can open a
&lt;strong&gt;file-backed&lt;/strong&gt; store — SQLite, SQLCipher, DuckDB — without a bind mount. It does not deploy: terminal
sessions are a dev-loop feature, so the resource is excluded from the manifest and anything you publish
still wants &lt;code dir=&quot;auto&quot;&gt;AddDocumentDbAdmin&lt;/code&gt;. &lt;code dir=&quot;auto&quot;&gt;DocumentDbAdminTerminalTool.Local&lt;/code&gt; covers a repo that pins the tool in
its local manifest instead of expecting a global install.&lt;/p&gt;
&lt;p&gt;Note that 13.4 moves the hosting package’s Aspire floor to &lt;strong&gt;13.5&lt;/strong&gt;, since &lt;code dir=&quot;auto&quot;&gt;WithTerminal&lt;/code&gt; doesn’t exist
before it.&lt;/p&gt;
&lt;aside aria-label=&quot;Read-only when it isn’t yours&quot;&gt;&lt;p aria-hidden=&quot;true&quot;&gt;Read-only when it isn’t yours&lt;/p&gt;&lt;div&gt;&lt;p&gt;Both front ends expose a full editor and an open SQL prompt over every referenced store. Chain
&lt;code dir=&quot;auto&quot;&gt;.WithReadOnly()&lt;/code&gt; for anything beyond a local AppHost, and &lt;code dir=&quot;auto&quot;&gt;.WithSecretKey(...)&lt;/code&gt; so saved profiles aren’t
protected by a key generated next to them. For an AppHost that publishes a &lt;strong&gt;public demo&lt;/strong&gt;, add
&lt;code dir=&quot;auto&quot;&gt;.WithoutAi()&lt;/code&gt; — it removes the &lt;a href=&quot;https://www.shinylib.net/documentdb/admin/assistant/&quot;&gt;assistant&lt;/a&gt; outright rather than hiding it, so
a visitor is never invited to paste their own API key into a database browser they don’t control.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;
&lt;div&gt;&lt;h2 id=&quot;what-it-deliberately-doesnt-do&quot;&gt;What it deliberately doesn’t do&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This is a &lt;strong&gt;server-tier&lt;/strong&gt; convenience. It does nothing for DocumentDb’s offline-first core — SQLite on
device, LiteDB, IndexedDB in the browser — because those never touch an AppHost. And it covers the
relational providers plus SQLite (PostgreSQL, CockroachDB, SQL Server, MySQL, MariaDB, SQLite); MongoDB and
Cosmos need divergent client-registration paths and are a planned follow-up.&lt;/p&gt;
&lt;p&gt;Within that scope, though, the payoff is real: the provider choice and the seed strategy live in the
AppHost, and the consuming code is a single line that works regardless of which one the AppHost picked.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.DocumentDb.Aspire.Hosting&lt;/span&gt;&lt;span&gt;   &lt;/span&gt;&lt;span&gt;# AppHost&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.DocumentDb.Aspire.Client&lt;/span&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;# service&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.DocumentDb.Aspire.Orleans&lt;/span&gt;&lt;span&gt;   &lt;/span&gt;&lt;span&gt;# silo&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/aspire&quot;&gt;Aspire integration docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/admin/aspire/&quot;&gt;Admin UI → Aspire AppHost&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/orleans&quot;&gt;Orleans provider&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/documentdb/orleans-streams&quot;&gt;Orleans streams&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/shinyorg/DocumentDb&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>documentdb</category><category>aspire</category><category>feature</category></item><item><title>DocumentDB in .NET Orleans</title><link>https://www.shinylib.net/blog/2026/08/documentdb-orleans-stack/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/08/documentdb-orleans-stack/</guid><description>Membership, grain storage, reminders, grain directory and — as of 13.2 — durable streams, all on one IDocumentStore and one connection string. Grain state stays queryable JSON you can read without activating a grain, and the stream backlog is a table you can open in the admin tool.</description><pubDate>Fri, 21 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.Orleans&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.Orleans?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.DocumentDb.Orleans&quot; alt=&quot;NuGet package Shiny.DocumentDb.Orleans&quot;&gt;&lt;/a&gt;
&lt;p&gt;Microsoft Orleans is excellent at the part that is hard — virtual actors, placement, transparent
activation. Persistence is the part it leaves to you, and it leaves it to you &lt;em&gt;five separate times&lt;/em&gt;:
cluster membership, grain storage, reminders, the grain directory, and streams are five independent
provider contracts, historically satisfied by five different packages with five different setup
conventions, storage layouts and operational stories.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/orleans&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Orleans&lt;/code&gt;&lt;/a&gt; implements all five against a single abstraction —
&lt;code dir=&quot;auto&quot;&gt;IDocumentStore&lt;/code&gt;. One connection string, one backup, one set of tables, and everything Orleans persists
is &lt;strong&gt;structured JSON you can query&lt;/strong&gt; rather than an opaque serialized blob.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.DocumentDb.Orleans&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;five-contracts-one-store&quot;&gt;Five contracts, one store&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;siloBuilder&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddDocumentDbGrainStorage&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Default&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.DatabaseProvider &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PostgreSqlDatabaseProvider&lt;/span&gt;&lt;span&gt;(cs))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddDocumentDbReminders&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt;           &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.DatabaseProvider &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PostgreSqlDatabaseProvider&lt;/span&gt;&lt;span&gt;(cs))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddDocumentDbClustering&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt;          &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.DatabaseProvider &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PostgreSqlDatabaseProvider&lt;/span&gt;&lt;span&gt;(cs))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddDocumentDbGrainDirectory&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Default&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.DatabaseProvider &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PostgreSqlDatabaseProvider&lt;/span&gt;&lt;span&gt;(cs))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddDocumentDbStreams&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Default&quot;&lt;/span&gt;&lt;span&gt;,    &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.DatabaseProvider &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PostgreSqlDatabaseProvider&lt;/span&gt;&lt;span&gt;(cs));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Because DocumentDb is schema-free, there are &lt;strong&gt;no setup scripts&lt;/strong&gt; — the tables are created on demand at
silo start. Each store has its own default table (&lt;code dir=&quot;auto&quot;&gt;orleans_default&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;orleans_reminders&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;orleans_membership&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;orleans_graindirectory&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;orleans_streams&lt;/code&gt;) and they all share the same
&lt;code dir=&quot;auto&quot;&gt;OrleansStoreOptions&lt;/code&gt; shape: give it a relational &lt;code dir=&quot;auto&quot;&gt;DatabaseProvider&lt;/code&gt; and it builds and owns its store, or
give it a &lt;code dir=&quot;auto&quot;&gt;StoreFactory&lt;/code&gt; and it uses one you built — which is the escape hatch for MongoDB, Cosmos, LiteDB
or anything else.&lt;/p&gt;
&lt;p&gt;MongoDB and Cosmos also get first-class companion packages, and it’s worth saying plainly that &lt;strong&gt;there is
no first-party Orleans MongoDB provider&lt;/strong&gt;, so this fills a real gap:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;siloBuilder.&lt;/span&gt;&lt;span&gt;AddMongoDbGrainStorage&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Default&quot;&lt;/span&gt;&lt;span&gt;, connectionString, &lt;/span&gt;&lt;span&gt;databaseName&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;orleans&quot;&lt;/span&gt;&lt;span&gt;);   &lt;/span&gt;&lt;span&gt;// Shiny.DocumentDb.Orleans.MongoDb&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;siloBuilder.&lt;/span&gt;&lt;span&gt;AddCosmosDbGrainStorage&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Default&quot;&lt;/span&gt;&lt;span&gt;, connectionString, &lt;/span&gt;&lt;span&gt;databaseName&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;orleans&quot;&lt;/span&gt;&lt;span&gt;);  &lt;/span&gt;&lt;span&gt;// Shiny.DocumentDb.Orleans.CosmosDb&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;grain-state-you-can-query-without-activating-a-grain&quot;&gt;Grain state you can query without activating a grain&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Orleans grain storage is a point key/value contract — &lt;code dir=&quot;auto&quot;&gt;Read&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;Write&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;Clear&lt;/code&gt; by grain id — by design.
The grain is the consistency boundary, so state is meant to be reached through the activation that owns it,
and a query surface on the storage provider would both bypass that boundary and require every provider to
understand the shape of the state it persists. Three operations keyed by grain id are what make the contract
implementable over blob storage, a table, ADO.NET, Redis or a file alike.&lt;/p&gt;
&lt;p&gt;The consequence is that cross-grain questions are answered by grains. “Which shopping carts are over $1,000?”
means activating every cart — a silo round trip that places the grain, deserializes its state and runs
&lt;code dir=&quot;auto&quot;&gt;OnActivateAsync&lt;/code&gt; — and because the built-in providers write state as an opaque blob, the database cannot
answer it instead.&lt;/p&gt;
&lt;p&gt;This provider keeps the same contract but stores each grain’s state as structured JSON under &lt;code dir=&quot;auto&quot;&gt;$.state&lt;/code&gt;, in an
ordinary table — so the rows the runtime reads by key are also readable as documents. Point a read-only store
at the same table and ask:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;opts&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;DocumentStoreOptions&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;DatabaseProvider      &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PostgreSqlDatabaseProvider&lt;/span&gt;&lt;span&gt;(connectionString),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;JsonSerializerOptions &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;JsonSerializerOptions&lt;/span&gt;&lt;span&gt; { PropertyNamingPolicy &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; JsonNamingPolicy.CamelCase }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;};&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;DocumentDbGrainStorage.&lt;/span&gt;&lt;span&gt;ConfigureGrainState&lt;/span&gt;&lt;span&gt;(opts, &lt;/span&gt;&lt;span&gt;&quot;orleans_default&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;readStore&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;DocumentStore&lt;/span&gt;&lt;span&gt;(opts);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Every ShoppingCart grain over 1000 — no grain activated, no silo involved.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;bigCarts&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; readStore.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;GrainStateRecord&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;json_extract(Data, &apos;$.state.total&apos;) &gt; @min&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;parameters&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; { min &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;1000&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Reporting, dashboards, ops tooling, bulk inspection, analytics — everything that is painful-to-impossible
when the only door into grain state is activating the grain becomes a plain document query.&lt;/p&gt;
&lt;aside aria-label=&quot;It’s a read model, not the live grain&quot;&gt;&lt;p aria-hidden=&quot;true&quot;&gt;It’s a read model, not the live grain&lt;/p&gt;&lt;div&gt;&lt;p&gt;These queries see the &lt;strong&gt;last persisted&lt;/strong&gt; state. An activated grain may be holding newer state it hasn’t
flushed (Orleans writes only when the grain calls &lt;code dir=&quot;auto&quot;&gt;WriteStateAsync&lt;/code&gt;), and the queries take no grain locks
and don’t serialize with grain turns. Treat it as an eventually-consistent read model — perfect for
reporting and ops, not a substitute for calling the grain when you need the authoritative answer.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;
&lt;div&gt;&lt;h2 id=&quot;a-free-audit-trail&quot;&gt;A free audit trail&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Grain state is a &lt;code dir=&quot;auto&quot;&gt;GrainStateRecord&lt;/code&gt; document like any other, which means it can opt into DocumentDb’s
&lt;a href=&quot;https://www.shinylib.net/documentdb/temporal/&quot;&gt;temporal history&lt;/a&gt;. One line gives you a queryable record of every mutation a grain
has ever persisted — no event sourcing to design, no extra infrastructure:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;opts.&lt;/span&gt;&lt;span&gt;ConfigureDocument&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;GrainStateRecord&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;cfg&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; cfg.&lt;/span&gt;&lt;span&gt;MapTemporal&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; t.MaxVersions &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;100&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;history&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; temporalStore.&lt;/span&gt;&lt;span&gt;History&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;GrainStateRecord&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;&quot;cart|user-42&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;streams--the-piece-that-completes-the-set&quot;&gt;Streams — the piece that completes the set&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/orleans-streams&quot;&gt;Persistent Orleans streams&lt;/a&gt; landed in 13.2 and are the newest member of the
stack. Orleans’ in-memory stream provider doesn’t survive a restart; every durable alternative means
running a queue service. This one is durable on the database your cluster already uses.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;siloBuilder.&lt;/span&gt;&lt;span&gt;AddDocumentDbStreams&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Default&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.DatabaseProvider &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PostgreSqlDatabaseProvider&lt;/span&gt;&lt;span&gt;(connectionString);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;// o.TotalQueueCount = 8;                       // default&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;// o.Retention       = TimeSpan.FromHours(1);   // default; null keeps everything&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Producing and consuming is the ordinary Orleans stream API — nothing about the grain code is
provider-specific.&lt;/p&gt;
&lt;aside aria-label=&quot;Not an Event Hub replacement&quot;&gt;&lt;p aria-hidden=&quot;true&quot;&gt;Not an Event Hub replacement&lt;/p&gt;&lt;div&gt;&lt;p&gt;This is a database-backed queue. Expect &lt;strong&gt;thousands&lt;/strong&gt; of events per second on PostgreSQL, not the hundreds
of thousands an event-streaming platform delivers. It exists so a team already running PostgreSQL or SQL
Server gets durable streams without a second piece of infrastructure — not to compete with Kafka on scale.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;
&lt;div&gt;&lt;h3 id=&quot;why-theres-a-counter-row-and-not-a-bigserial&quot;&gt;Why there’s a counter row and not a &lt;code dir=&quot;auto&quot;&gt;BIGSERIAL&lt;/code&gt;&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The obvious implementation of “monotonic position per queue” is an identity column. It is a correctness
trap, and it’s worth understanding why. Sequence values are handed out at &lt;strong&gt;insert&lt;/strong&gt; time; rows become
visible at &lt;strong&gt;commit&lt;/strong&gt; time, and those two orders are not the same:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Transaction A inserts, takes sequence 5.&lt;/li&gt;
&lt;li&gt;Transaction B inserts, takes 6, and commits first.&lt;/li&gt;
&lt;li&gt;The receiver reads up to 6 and advances its cursor past 5.&lt;/li&gt;
&lt;li&gt;Transaction A commits. Event 5 is behind the cursor and is &lt;strong&gt;never delivered&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Silently, and only under load. So instead each queue owns a counter row, and an enqueue reserves its
position under a row lock inside the same transaction:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;BEGIN&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;counter = Get(queueId, LockMode.Update)   -- SELECT … FOR UPDATE&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;seq     = counter.Next++&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;INSERT the event row with Seq = seq&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;COMMIT&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The lock does double duty: a second producer blocks until the first &lt;em&gt;commits&lt;/em&gt;, so assignment order and
commit order are the same order by construction, and the sequence is gap-free. The trade is explicit —
enqueue throughput per queue is bounded by the lock hold time on one row — and &lt;code dir=&quot;auto&quot;&gt;TotalQueueCount&lt;/code&gt; is the
dial, since each queue has its own counter row.&lt;/p&gt;
&lt;p&gt;That row lock is also why the backend list is short: PostgreSQL, SQL Server, MySQL, MariaDB, Oracle and
CockroachDB. It’s a &lt;strong&gt;capability check&lt;/strong&gt; (&lt;code dir=&quot;auto&quot;&gt;SupportsPessimisticLocking&lt;/code&gt;), not a name list, and it’s enforced
at silo start rather than on the first event — a cluster that boots and then drops events under load is a
much worse failure than one that refuses to boot.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;two-things-a-queue-backed-provider-cant-do&quot;&gt;Two things a queue-backed provider can’t do&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Rewind past the cache.&lt;/strong&gt; Behind Azure Queue or SQS the message is gone once it’s handed over, so a
subscriber resuming from an old &lt;code dir=&quot;auto&quot;&gt;StreamSequenceToken&lt;/code&gt; gets &lt;code dir=&quot;auto&quot;&gt;QueueCacheMissException&lt;/code&gt;. Here the row is still
in the table, so the cache replays it:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; stream.&lt;/span&gt;&lt;span&gt;SubscribeAsync&lt;/span&gt;&lt;span&gt;(handler, lastToken);   &lt;/span&gt;&lt;span&gt;// works across a silo restart&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The rewind window is exactly &lt;code dir=&quot;auto&quot;&gt;Retention&lt;/code&gt; — the sweep that bounds table growth is the same thing that
bounds how far back you can resume. Set it to the replay window you actually want and size the table for
it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Show you the backlog.&lt;/strong&gt; Stream events are ordinary documents, so you can look at them.
&lt;code dir=&quot;auto&quot;&gt;IStreamAdmin&lt;/code&gt; is the in-process view, and &lt;a href=&quot;https://www.shinylib.net/documentdb/admin&quot;&gt;ShinyDocDbMyAdmin&lt;/a&gt; has a Streams screen
answering the same questions in both the web and terminal front ends:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;admin&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; services.&lt;/span&gt;&lt;span&gt;GetRequiredKeyedService&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;IStreamAdmin&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;&quot;Default&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;foreach&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;stuck&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; admin.&lt;/span&gt;&lt;span&gt;StuckStreams&lt;/span&gt;&lt;span&gt;(TimeSpan.&lt;/span&gt;&lt;span&gt;FromMinutes&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;5&lt;/span&gt;&lt;span&gt;)))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;logger.&lt;/span&gt;&lt;span&gt;LogWarning&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;{Stream}: {Count} undelivered since {Since}&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;stuck.StreamId, stuck.UndeliveredCount, stuck.OldestUndeliveredAt);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Watch &lt;em&gt;oldest undelivered&lt;/em&gt; rather than depth — depth alone can’t tell a busy queue from a dead pulling
agent, but age can. &lt;code dir=&quot;auto&quot;&gt;IStreamAdmin&lt;/code&gt; is deliberately read-only: an outbox message is a unit of work someone
owns, so requeueing it means something, but a stream event is a position in a gap-free sequence that every
subscriber holds a cursor into. Deleting one tears a hole in that sequence. A stuck stream gets fixed on
the consumer side.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;concurrency-properly&quot;&gt;Concurrency, properly&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Orleans’ ETag is the contract that stops two activations clobbering each other during a failover window.
It maps onto the document version, and each provider honours it with a genuinely atomic compare-and-swap:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Orleans&lt;/th&gt;
&lt;th&gt;Shiny.DocumentDb&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;document key&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Id = &quot;{stateName}|{grainId}&quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ETag&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;GrainStateRecord.Version&lt;/code&gt; (via &lt;code dir=&quot;auto&quot;&gt;cfg.MapVersionProperty&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;concurrency conflict&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;ConcurrencyException&lt;/code&gt; → &lt;code dir=&quot;auto&quot;&gt;InconsistentStateException&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;state blob&lt;/td&gt;
&lt;td&gt;nested &lt;code dir=&quot;auto&quot;&gt;JsonElement&lt;/code&gt; (queryable, not opaque)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Relational providers fold the version check into &lt;code dir=&quot;auto&quot;&gt;UPDATE … WHERE&lt;/code&gt; and verify the row count, MongoDB uses
an atomic version-predicate filter, and Cosmos uses a native &lt;code dir=&quot;auto&quot;&gt;IfMatchEtag&lt;/code&gt;. A stale write loses the race
and surfaces as &lt;code dir=&quot;auto&quot;&gt;InconsistentStateException&lt;/code&gt; — exactly what Orleans expects. The PostgreSQL and MongoDB
paths, including the stale-write conflict, are covered by integration tests.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;know-your-backend&quot;&gt;Know your backend&lt;/h2&gt;&lt;/div&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Backends&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Recommended&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PostgreSQL, SQL Server, MySQL, Oracle&lt;/td&gt;
&lt;td&gt;Atomic CAS folded into &lt;code dir=&quot;auto&quot;&gt;UPDATE … WHERE&lt;/code&gt;; ETag honoured across failover windows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Supported&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MongoDB&lt;/td&gt;
&lt;td&gt;Good key distribution; atomic CAS via version-predicate filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Limited / dev&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SQLite, LiteDB, IndexedDB, DuckDB&lt;/td&gt;
&lt;td&gt;Single-writer / embedded / analytical — fine for dev, single-silo, or edge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Use with care&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cosmos DB&lt;/td&gt;
&lt;td&gt;CAS is correct, but it partitions by grain type — weigh the 20 GB / hot-partition trade before large-scale use&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Three limits are worth knowing before production:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Membership needs real multi-document transactions.&lt;/strong&gt; The per-silo rows and the global table-version row
are updated together, each gated on its own version, because that’s how Orleans’ table-version protocol
works. Relational or a MongoDB replica set — &lt;strong&gt;not&lt;/strong&gt; Cosmos, whose transactional batches are
single-partition. Grain storage, reminders and the grain directory have no such requirement.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Streams need row-level locking&lt;/strong&gt; — see above.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The silo host is not an AOT target.&lt;/strong&gt; Grain-state and system-store &lt;em&gt;serialization&lt;/em&gt; goes reflection-free
when you assign a &lt;code dir=&quot;auto&quot;&gt;JsonSerializerContext&lt;/code&gt;, but &lt;code dir=&quot;auto&quot;&gt;Microsoft.Orleans.Runtime&lt;/code&gt; is codegen-heavy, so a fully
AOT-published silo isn’t a goal here.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;reflection-free-serialization-when-you-want-it&quot;&gt;Reflection-free serialization when you want it&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The provider’s own envelope types — grain-state record, reminders, membership, grain-directory rows — are
&lt;strong&gt;always&lt;/strong&gt; source-generated. The one generic piece is your grain state &lt;code dir=&quot;auto&quot;&gt;T&lt;/code&gt;. Point a &lt;code dir=&quot;auto&quot;&gt;JsonSerializerContext&lt;/code&gt;
at it and that goes reflection-free too:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;JsonSerializable&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;typeof&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;CartState&lt;/span&gt;&lt;span&gt;))]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;JsonSerializable&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;typeof&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;UserPrefs&lt;/span&gt;&lt;span&gt;))]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;partial&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;GrainStateContext&lt;/span&gt;&lt;span&gt; : &lt;/span&gt;&lt;span&gt;JsonSerializerContext&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;siloBuilder.&lt;/span&gt;&lt;span&gt;AddDocumentDbGrainStorage&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Default&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.DatabaseProvider      &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PostgreSqlDatabaseProvider&lt;/span&gt;&lt;span&gt;(cs);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.JsonSerializerOptions &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;JsonSerializerOptions&lt;/span&gt;&lt;span&gt; { TypeInfoResolver &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; GrainStateContext.Default };&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.UseReflectionFallback &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;false&lt;/span&gt;&lt;span&gt;;   &lt;/span&gt;&lt;span&gt;// throw on an unregistered state type instead of reflecting&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Purely opt-in — leave the defaults and you keep the familiar reflection-based behaviour. The same knobs
exist on the reminder, clustering, grain-directory and stream options.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;and-in-aspire-its-one-line&quot;&gt;And in Aspire, it’s one line&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;If your silo runs under &lt;a href=&quot;https://www.shinylib.net/documentdb/aspire&quot;&gt;.NET Aspire&lt;/a&gt;, the AppHost provisions the store and the silo
points the whole stack at it:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.&lt;/span&gt;&lt;span&gt;AddDocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orleans&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.&lt;/span&gt;&lt;span&gt;UseOrleans&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;silo&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; silo.&lt;/span&gt;&lt;span&gt;UseAspireDocumentDb&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orleans&quot;&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s grain storage, reminders, clustering and the grain directory on the Aspire-provisioned store —
connection, health check and telemetry included. Streams are one flag away and deliberately opt-in.
The &lt;a href=&quot;https://www.shinylib.net/blog/2026/08/documentdb-aspire/&quot;&gt;next post in this pair&lt;/a&gt; covers the Aspire integration properly.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;get-started&quot;&gt;Get started&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.DocumentDb.Orleans&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# optional companions&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.DocumentDb.Orleans.MongoDb&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.DocumentDb.Orleans.CosmosDb&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/orleans&quot;&gt;Orleans provider docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/orleans-streams&quot;&gt;Orleans streams docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/temporal/&quot;&gt;Temporal history&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/shinyorg/DocumentDb&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>documentdb</category><category>orleans</category><category>feature</category></item><item><title>Level Up Animation with Keyframes for MAUI &amp; Blazor</title><link>https://www.shinylib.net/blog/2026/08/keyframes-motion-icons-shinybutton/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/08/keyframes-motion-icons-shinybutton/</guid><description>Three layers of one animation story — a seekable keyframe engine for .NET MAUI with CSS @keyframes semantics in XAML, 42 motion icons compiled from a single spec into a drawn scene on MAUI and real CSS keyframes on Blazor, and a ShinyButton that puts both to work in the most ordinary interaction in your app.</description><pubDate>Thu, 20 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Animation in .NET MAUI has always had the same shape: &lt;code dir=&quot;auto&quot;&gt;ViewExtensions.FadeTo&lt;/code&gt;, a &lt;code dir=&quot;auto&quot;&gt;Task&lt;/code&gt;, and a &lt;code dir=&quot;auto&quot;&gt;bool&lt;/code&gt; somewhere
tracking whether you are mid-animation. It works right up until someone asks for the thing every design tool
does for free — scrub it, reverse it halfway, hold the final pose, export it as a GIF for the marketing page.&lt;/p&gt;
&lt;p&gt;This post covers three pieces that ship together and build on each other:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/controls/keyframe/&quot;&gt;Keyframe&lt;/a&gt;&lt;/strong&gt; — the CSS &lt;code dir=&quot;auto&quot;&gt;@keyframes&lt;/code&gt; model in XAML and a fluent C# timeline API, where
evaluating an animation is a pure function of time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/controls/motion-icons/&quot;&gt;Motion Icons&lt;/a&gt;&lt;/strong&gt; — 42 animated icons authored once and compiled into a drawn scene
on MAUI and real CSS &lt;code dir=&quot;auto&quot;&gt;@keyframes&lt;/code&gt; on Blazor.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/controls/button/&quot;&gt;ShinyButton&lt;/a&gt;&lt;/strong&gt; — where the other two land in a real app: press a button, wait for the
network, see whether it worked.&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Maui.Controls.Keyframe&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Maui.Controls.Keyframe?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Maui.Controls.Keyframe&quot; alt=&quot;NuGet package Shiny.Maui.Controls.Keyframe&quot;&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Maui.Controls.Keyframe.Export&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Maui.Controls.Keyframe.Export?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Maui.Controls.Keyframe.Export&quot; alt=&quot;NuGet package Shiny.Maui.Controls.Keyframe.Export&quot;&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Maui.Controls&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Maui.Controls?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Maui.Controls&quot; alt=&quot;NuGet package Shiny.Maui.Controls&quot;&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Blazor.Controls&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Blazor.Controls?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Blazor.Controls&quot; alt=&quot;NuGet package Shiny.Blazor.Controls&quot;&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;aside aria-label=&quot;Try it in the browser first&quot;&gt;&lt;p aria-hidden=&quot;true&quot;&gt;Try it in the browser first&lt;/p&gt;&lt;div&gt;&lt;p&gt;Motion icons and &lt;code dir=&quot;auto&quot;&gt;ShinyButton&lt;/code&gt; are both live in the &lt;strong&gt;&lt;a href=&quot;https://shinyorg.github.io/controls/&quot;&gt;Blazor Playground&lt;/a&gt;&lt;/strong&gt; —
no install, no clone. Hover the icons, press the buttons, watch the busy states run.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;
&lt;div&gt;&lt;h2 id=&quot;layer-1--keyframes&quot;&gt;Layer 1 — Keyframes&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.Maui.Controls.Keyframe&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;xmlns:kf=&quot;http://shiny.net/maui/keyframe&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;There is no &lt;code dir=&quot;auto&quot;&gt;builder.Use…()&lt;/code&gt; call. That one XAML namespace is the whole installation.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Animate.Keyframes&lt;/code&gt; is an attached property on any &lt;code dir=&quot;auto&quot;&gt;VisualElement&lt;/code&gt;, and it is deliberately a direct analogue of a
CSS &lt;code dir=&quot;auto&quot;&gt;@keyframes&lt;/code&gt; rule plus the &lt;code dir=&quot;auto&quot;&gt;animation-*&lt;/code&gt; properties that drive it — &lt;code dir=&quot;auto&quot;&gt;Duration&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Delay&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Iterations&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;Direction&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Fill&lt;/code&gt;, and per-key easing all behave the way the web spec says they do:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Border&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;kf:Animate.Keyframes&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;kf:Keyframes&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Duration&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;0:0:1.2&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Iterations&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Infinite&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Direction&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Alternate&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Fill&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Both&quot;&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;kf:Track&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Property&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Scale&quot;&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;kf:Key&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Offset&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;0&quot;&lt;/span&gt;&lt;span&gt;   &lt;/span&gt;&lt;span&gt;Value&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;1&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;kf:Key&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Offset&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;0.5&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Value&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;1.15&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Easing&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;CubicOut&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;kf:Key&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Offset&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;1&quot;&lt;/span&gt;&lt;span&gt;   &lt;/span&gt;&lt;span&gt;Value&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;1&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;kf:Track&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;kf:Track&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Property&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;BackgroundColor&quot;&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;kf:Key&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Offset&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;0&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Value&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;#2563EB&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;kf:Key&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Offset&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;1&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Value&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;#EC4899&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;kf:Track&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;kf:Keyframes&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;kf:Animate.Keyframes&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;Border&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The same thing in C#, because a designer-authored animation and a data-driven one should not be two different
engines:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;timeline&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; TimelineBuilder&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Create&lt;/span&gt;&lt;span&gt;(TimeSpan.&lt;/span&gt;&lt;span&gt;FromSeconds&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;1.2&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;PingPong&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;RepeatForever&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Fill&lt;/span&gt;&lt;span&gt;(FillMode.Both)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Animate&lt;/span&gt;&lt;span&gt;(box, (&lt;/span&gt;&lt;span&gt;v&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; v.Scale &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; x, &lt;/span&gt;&lt;span&gt;k&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; k&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;From&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Key&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;0.5&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;1.15&lt;/span&gt;&lt;span&gt;, Easings.CubicOut)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;To&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Build&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;player&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; box.&lt;/span&gt;&lt;span&gt;Play&lt;/span&gt;&lt;span&gt;(timeline);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;the-one-design-constraint&quot;&gt;The one design constraint&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;IAnimationNode.Evaluate(t)&lt;/code&gt; computes the state at &lt;code dir=&quot;auto&quot;&gt;t&lt;/code&gt; &lt;strong&gt;from the keyframes alone&lt;/strong&gt;. It never reads the previous
frame and it never accumulates. Everything interesting falls out of that single property:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You want to…&lt;/th&gt;
&lt;th&gt;Do this&lt;/th&gt;
&lt;th&gt;Not this&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scrub from a &lt;code dir=&quot;auto&quot;&gt;Slider&lt;/code&gt; or a gesture&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;player.SeekProgress(x)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rebuild a timeline at the new position&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reverse mid-flight&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;player.Rate = -1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Build a second, reversed timeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export frames&lt;/td&gt;
&lt;td&gt;Sample at exact frame times — identical bytes every run&lt;/td&gt;
&lt;td&gt;Render off a real-time clock and hope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test timing&lt;/td&gt;
&lt;td&gt;Step a &lt;code dir=&quot;auto&quot;&gt;ManualClock&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Task.Delay&lt;/code&gt; and a tolerance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Rate&lt;/code&gt; scales the per-frame delta rather than the position, so flipping it mid-flight carries on from wherever the
animation actually is instead of jumping to a rescaled position. That is the difference between “reverse” and
“restart backwards”, and it is the thing you cannot retrofit onto an accumulating animator.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;easing-you-can-paste-in&quot;&gt;Easing you can paste in&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Curves are a plain &lt;code dir=&quot;auto&quot;&gt;delegate double EasingFunction(double t)&lt;/code&gt;, there are 38 named ones, and the string form parses
CSS function syntax — because copying a curve out of a design tool or a devtools panel is by far the most common
way anyone arrives at one:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;kf:Key&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Offset&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;0&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Value&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;-200&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Easing&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;cubic-bezier(0.34, 1.56, 0.64, 1)&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;kf:Keyframes&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Easing&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;steps(8)&quot;&lt;/span&gt;&lt;span&gt; ... /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;kf:Keyframes&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Easing&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;spring(0.35, 14)&quot;&lt;/span&gt;&lt;span&gt; ... /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Easings.Spring&lt;/code&gt; is a &lt;strong&gt;closed-form solution&lt;/strong&gt; of the spring ODE rather than a numeric integration — cheap enough
to evaluate per frame, and still a pure function of &lt;code dir=&quot;auto&quot;&gt;t&lt;/code&gt;, so a spring seeks and reverses like every other curve
here. An unrecognised easing string throws a &lt;code dir=&quot;auto&quot;&gt;FormatException&lt;/code&gt; listing every registered name at parse time,
rather than silently running linear at runtime.&lt;/p&gt;
&lt;p&gt;Register your own for XAML in one line:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;EasingCatalog.&lt;/span&gt;&lt;span&gt;Register&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Smoothstep&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; t &lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt; t &lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;3d&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;2d&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt; t));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;details-that-bite-in-production&quot;&gt;Details that bite in production&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;A few deliberate choices worth knowing about:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Oklab colour blending by default.&lt;/strong&gt; sRGB interpolation dips through grey at the midpoint; Oklab does not.
&lt;code dir=&quot;auto&quot;&gt;ColorInterpolator.Srgb&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;.LinearRgb&lt;/code&gt; are still there when you want them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shortest-arc angles.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;Rotation&lt;/code&gt; from 350° to 10° turns forward 20°, not back 340°. Use &lt;code dir=&quot;auto&quot;&gt;Spin&lt;/code&gt; when multiple
turns are the point.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Implicit keyframes.&lt;/strong&gt; Omit &lt;code dir=&quot;auto&quot;&gt;Value&lt;/code&gt; on a key and it resolves to the target’s live value when playback starts,
so a re-triggered animation continues from where it is instead of snapping.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AOT and trim safe.&lt;/strong&gt; The animatable-property registry is hand-registered delegates — not reflection, not
compiled &lt;code dir=&quot;auto&quot;&gt;Expression&lt;/code&gt; trees, both of which work in the emulator and vanish on device under Native AOT.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Weak targets.&lt;/strong&gt; An &lt;code dir=&quot;auto&quot;&gt;Iterations=&quot;Infinite&quot;&lt;/code&gt; animation on a popped page goes inert and gets collected rather
than pinning the visual tree, so infinite loops are safe to use freely.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One clock per window.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;MauiClock.For(element)&lt;/code&gt; returns the clock shared by every animation on that
element’s dispatcher, and it idles by itself when nothing is listening. &lt;code dir=&quot;auto&quot;&gt;Pause()&lt;/code&gt; detaches from the clock
rather than ignoring ticks, so the platform can stop producing frames entirely.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h3 id=&quot;storyboards-scenes-and-export&quot;&gt;Storyboards, scenes, and export&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Storyboard&lt;/code&gt; composes timelines on a shared clock and is itself an &lt;code dir=&quot;auto&quot;&gt;IAnimationNode&lt;/code&gt;, so storyboards nest — a
staggered list entrance can be one item inside a larger sequence:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;storyboard&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Storyboard&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Add&lt;/span&gt;&lt;span&gt;(introTimeline)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Then&lt;/span&gt;&lt;span&gt;(mainTimeline, &lt;/span&gt;&lt;span&gt;gap&lt;/span&gt;&lt;span&gt;: TimeSpan.&lt;/span&gt;&lt;span&gt;FromMilliseconds&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;200&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Stagger&lt;/span&gt;&lt;span&gt;(cardTimelines, &lt;/span&gt;&lt;span&gt;interval&lt;/span&gt;&lt;span&gt;: TimeSpan.&lt;/span&gt;&lt;span&gt;FromMilliseconds&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;120&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;KeyframeScene&lt;/code&gt; runs the &lt;em&gt;same&lt;/em&gt; timing model against a layer tree drawn onto a canvas rather than views in the
visual tree — the Lottie-shaped lane, for loaders, illustrated micro-animations, progress indicators and shape
morphs. Both are &lt;code dir=&quot;auto&quot;&gt;IAnimationNode&lt;/code&gt;s on the same clock, so one storyboard can sequence real views and scene layers
together, and &lt;code dir=&quot;auto&quot;&gt;KeyframeView.Progress&lt;/code&gt; is two-way, which gives you a scrubber for a &lt;code dir=&quot;auto&quot;&gt;Slider&lt;/code&gt; binding.&lt;/p&gt;
&lt;p&gt;And because sampling is exact, a scene can be rendered offscreen, deterministically:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;exporter&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;FrameExporter&lt;/span&gt;&lt;span&gt;(scene);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;options&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ExportOptions&lt;/span&gt;&lt;span&gt; { Fps &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;25&lt;/span&gt;&lt;span&gt;, Scale &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;2.0&lt;/span&gt;&lt;span&gt; };&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;GifEncoder.&lt;/span&gt;&lt;span&gt;EncodeToFile&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;out.gif&quot;&lt;/span&gt;&lt;span&gt;, exporter.&lt;/span&gt;&lt;span&gt;Frames&lt;/span&gt;&lt;span&gt;(options), options.Fps);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Frames are enumerated lazily, frame times are computed as &lt;code dir=&quot;auto&quot;&gt;index / fps&lt;/code&gt; in ticks so a long export cannot drift,
and the GIF encoder is pure managed code. Export is a separate package because it is the only part of Keyframe
that needs a rasterizer — and it targets plain .NET rather than the platform TFMs, so it runs from a console app,
a build step, or CI.&lt;/p&gt;
&lt;aside aria-label=&quot;Why MAUI only&quot;&gt;&lt;p aria-hidden=&quot;true&quot;&gt;Why MAUI only&lt;/p&gt;&lt;div&gt;&lt;p&gt;There is no Blazor counterpart to the XAML surface, and there will not be — the web already has &lt;code dir=&quot;auto&quot;&gt;@keyframes&lt;/code&gt;,
natively, composited off the main thread. The timing engine itself (&lt;code dir=&quot;auto&quot;&gt;Shiny.Controls.Keyframe.Shared&lt;/code&gt;) is
host-neutral and depends only on &lt;code dir=&quot;auto&quot;&gt;Microsoft.Maui.Graphics&lt;/code&gt;. Within MAUI it runs on &lt;strong&gt;every head&lt;/strong&gt;: nothing in the
package touches a platform SDK, so iOS, Android, Windows, Mac Catalyst and the macOS AppKit head all consume one
&lt;code dir=&quot;auto&quot;&gt;net10.0&lt;/code&gt; target.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;
&lt;div&gt;&lt;h2 id=&quot;layer-2--motion-icons&quot;&gt;Layer 2 — Motion Icons&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;42 hand-drawn icons that animate. A bell that swings from its crown with the clapper catching up late. A
hamburger that morphs into a cross. A tick that draws itself on. A spinner whose arc chases its own tail.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;MotionIconView&lt;/code&gt; on MAUI, &lt;code dir=&quot;auto&quot;&gt;&amp;#x3C;MotionIcon&gt;&lt;/code&gt; on Blazor — both live in the &lt;strong&gt;core&lt;/strong&gt; packages, so there is nothing extra
to install and nothing to register.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;MAUI&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;shiny:MotionIconView&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Icon&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;bell&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Trigger&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Loop&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Interval&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;0:0:1.5&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;WidthRequest&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;32&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;HeightRequest&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;32&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;Blazor&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;MotionIcon&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Icon&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;bell&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Trigger&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;MotionTrigger.Loop&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Interval&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;TimeSpan.FromSeconds(1.5)&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Size&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;32&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;MAUI&lt;/strong&gt; (iOS)&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Triggers &amp;#x26; code-driven playback&lt;/th&gt;
&lt;th&gt;Presets &amp;#x26; scrubbing&lt;/th&gt;
&lt;th&gt;The icon set&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/motion-icons/s1.png&quot; width=&quot;220&quot; alt=&quot;Trigger, code-driven and preset demos on MAUI&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/motion-icons/s2.png&quot; width=&quot;220&quot; alt=&quot;Presets applied to any icon, and progress scrubbing&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/motion-icons/s3.png&quot; width=&quot;220&quot; alt=&quot;The built-in icon set on MAUI&quot;&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Blazor&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Triggers&lt;/th&gt;
&lt;th&gt;Driven from code&lt;/th&gt;
&lt;th&gt;Two-tone &amp;#x26; colour&lt;/th&gt;
&lt;th&gt;Presets on any icon&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/motion-icons/blazor-s1.png&quot; width=&quot;220&quot; alt=&quot;Loop, hover, press and appear triggers on Blazor&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/motion-icons/blazor-s2.png&quot; width=&quot;220&quot; alt=&quot;IsPlaying bound to a busy flag on Blazor&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/motion-icons/blazor-s3.png&quot; width=&quot;220&quot; alt=&quot;Colour and accent colour on Blazor&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/motion-icons/blazor-s4.png&quot; width=&quot;220&quot; alt=&quot;Motion presets applied to any icon on Blazor&quot;&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Those are single frames of things that only make sense in motion — the
&lt;strong&gt;&lt;a href=&quot;https://shinyorg.github.io/controls/&quot;&gt;playground&lt;/a&gt;&lt;/strong&gt; is the honest version.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;one-definition-two-very-different-engines&quot;&gt;One definition, two very different engines&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The artwork and its motion live in &lt;code dir=&quot;auto&quot;&gt;Shiny.Controls.MotionIcons.Shared&lt;/code&gt;, a dependency-free package both hosts
reference. What each host &lt;em&gt;does&lt;/em&gt; with that definition could hardly be more different:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;.NET MAUI&lt;/th&gt;
&lt;th&gt;Blazor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rendering&lt;/td&gt;
&lt;td&gt;a &lt;code dir=&quot;auto&quot;&gt;KeyframeScene&lt;/code&gt; on a &lt;code dir=&quot;auto&quot;&gt;GraphicsView&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;inline SVG&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Animation&lt;/td&gt;
&lt;td&gt;a keyframe &lt;code dir=&quot;auto&quot;&gt;Timeline&lt;/code&gt;, evaluated per frame&lt;/td&gt;
&lt;td&gt;compiled once to CSS &lt;code dir=&quot;auto&quot;&gt;@keyframes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Driven by&lt;/td&gt;
&lt;td&gt;the Keyframe engine’s &lt;code dir=&quot;auto&quot;&gt;Player&lt;/code&gt;, on one shared timer per window&lt;/td&gt;
&lt;td&gt;the browser’s compositor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C# per frame&lt;/td&gt;
&lt;td&gt;evaluate + redraw&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;none&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;That split is the whole point. On the web nothing about a motion icon needs a render loop — once the keyframes
are declared the browser composites them off the main thread, at the display’s refresh rate, and keeps going
while WebAssembly is busy elsewhere. A C# ticker driving re-renders would be slower, jankier, and would stop dead
the moment the app did some work.&lt;/p&gt;
&lt;p&gt;On MAUI there is no compositor to hand the work to, so an icon is drawn — but by the Keyframe engine rather than
by machinery of its own. Motion icons and hand-written timelines share one clock per window, one set of easing
curves, and one implementation of position, rate and baselines. Because both sides compile the &lt;em&gt;same&lt;/em&gt; spec, with
the same easing curves, an icon looks and moves the same in a MAUI app and in a browser.&lt;/p&gt;
&lt;p&gt;Where CSS has a keyword that means exactly the same thing as a &lt;code dir=&quot;auto&quot;&gt;MotionEase&lt;/code&gt; member, the generated stylesheet uses
it. Everything else — the overshoot and bounce curves CSS has no name for — is sampled into a &lt;code dir=&quot;auto&quot;&gt;linear()&lt;/code&gt; curve
rather than approximated with a “close enough” cubic-bezier, which is what would otherwise make a bounce bounce
differently in the browser than on the phone.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;triggers&quot;&gt;Triggers&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Trigger&lt;/code&gt; is a &lt;code dir=&quot;auto&quot;&gt;[Flags]&lt;/code&gt; enum defaulting to &lt;code dir=&quot;auto&quot;&gt;Hover | Press&lt;/code&gt; — hover for desktop, press for touch, so an icon does
something sensible everywhere without being told.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Behaviour&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Loop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs continuously. &lt;code dir=&quot;auto&quot;&gt;Interval&lt;/code&gt; inserts a resting gap between cycles — a bell that rings without pause reads as broken.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Hover&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs while the pointer is over it, then &lt;strong&gt;finishes the cycle it is in&lt;/strong&gt;, so a half-swung bell settles upright instead of snapping.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Press&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One play per tap or click.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Appear&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Plays once when the icon first becomes visible — an &lt;code dir=&quot;auto&quot;&gt;IntersectionObserver&lt;/code&gt; on the web, &lt;code dir=&quot;auto&quot;&gt;Loaded&lt;/code&gt; on MAUI.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Manual&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No automatic trigger. Bind &lt;code dir=&quot;auto&quot;&gt;IsPlaying&lt;/code&gt; to a busy flag, or call &lt;code dir=&quot;auto&quot;&gt;Play()&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;Stop()&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;StopAtCycleEnd()&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;Interval&lt;/code&gt; gap is folded into the animation itself rather than scheduled by a timer. A CSS animation has no
way to pause between iterations, so a spec that expressed the gap externally would need a JavaScript timer on the
web and a dispatcher timer on MAUI, and the two would drift. Squeezing the keys into the front of a longer cycle
and holding the resting pose through the remainder gets the same result out of
&lt;code dir=&quot;auto&quot;&gt;animation-iteration-count: infinite&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;On MAUI, &lt;code dir=&quot;auto&quot;&gt;Progress&lt;/code&gt; is two-way, so an icon can be scrubbed from a slider or a gesture — dragging morphs the
hamburger into a cross and back:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;shiny:MotionIconView&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Icon&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;menu&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Trigger&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Manual&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                      &lt;/span&gt;&lt;span&gt;Progress&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;{Binding Source={x:Reference Scrubber}, Path=Value}&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Slider&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;x:Name&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Scrubber&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Minimum&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;0&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Maximum&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;1&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;presets-and-your-own-artwork&quot;&gt;Presets, and your own artwork&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;A preset is motion that does not need to know what it is animating — &lt;code dir=&quot;auto&quot;&gt;Pulse&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Beat&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Spin&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Shake&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Wobble&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;Bounce&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Float&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Pop&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Tada&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Flip&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Swing&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Blink&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Draw&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Nudge&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Jiggle&lt;/code&gt;. Every one works on a
built-in icon, on raw &lt;code dir=&quot;auto&quot;&gt;PathData&lt;/code&gt; of your own, and on a &lt;code dir=&quot;auto&quot;&gt;MotionIconDefinition&lt;/code&gt; you assembled:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;shiny:MotionIconView&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Icon&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;star&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Motion&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Tada&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Trigger&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Hover&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;shiny:MotionIconView&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PathData&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;M12 2 3 20h18z&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Motion&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Pop&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Trigger&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Press&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Default&lt;/code&gt; is a fallback chain rather than a preset: it asks the icon for its own motion first, which is why
&lt;code dir=&quot;auto&quot;&gt;Icon=&quot;bell&quot;&lt;/code&gt; rings rather than merely pulsing, and lands on &lt;code dir=&quot;auto&quot;&gt;Pulse&lt;/code&gt; for artwork that has none.&lt;/p&gt;
&lt;p&gt;For custom artwork, an icon splits into parts for exactly one reason — a part is the unit a track can target:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;toggle&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;MotionIconDefinition&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;toggle&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;MotionIconPart&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;plate&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;M3 8h18v8H3z&quot;&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;MotionIconPart&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;knob&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;M11 12a3 3 0 1 1 6 0 3 3 0 0 1-6 0z&quot;&lt;/span&gt;&lt;span&gt;) { Origin &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;MotionPoint&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;14f&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;12f&lt;/span&gt;&lt;span&gt;) }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;],&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;MotionSpecBuilder.&lt;/span&gt;&lt;span&gt;Build&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;500&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;m&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; m&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;MoveX&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;knob&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;k&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; k&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;At&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;0d&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;0d&lt;/span&gt;&lt;span&gt;, MotionEase.BackOut)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;At&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;0.5d&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt;6d&lt;/span&gt;&lt;span&gt;, MotionEase.BackInOut)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;At&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;1d&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;0d&lt;/span&gt;&lt;span&gt;))));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;MotionIconLibrary.&lt;/span&gt;&lt;span&gt;Register&lt;/span&gt;&lt;span&gt;(toggle);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Register&lt;/code&gt; replaces a built-in as well as adding a new one, so an app with its own visual language swaps the
artwork for &lt;code dir=&quot;auto&quot;&gt;check&lt;/code&gt; once at startup rather than passing a definition in at every call site.&lt;/p&gt;
&lt;p&gt;There is deliberately &lt;strong&gt;no path-morph channel&lt;/strong&gt;. Every channel — &lt;code dir=&quot;auto&quot;&gt;Opacity&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;TranslateX/Y&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Rotate&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Scale&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;ScaleX/Y&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;StrokeWidth&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Trim&lt;/code&gt;, plus colour on &lt;code dir=&quot;auto&quot;&gt;Fill&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;Stroke&lt;/code&gt; — has a native, identically-behaving
implementation on both hosts, which is the only way one icon can be guaranteed to look the same in both.
Animating SVG’s &lt;code dir=&quot;auto&quot;&gt;d&lt;/code&gt; is not supported in every browser, so a morph channel would have meant hand-written fallbacks
the moment someone opened Firefox. Hinged and morphing icons are built from separate parts moved by transforms,
exactly as they would be in a design tool.&lt;/p&gt;
&lt;aside aria-label=&quot;Two rules that fail silently on MAUI&quot;&gt;&lt;p aria-hidden=&quot;true&quot;&gt;Two rules that fail silently on MAUI&lt;/p&gt;&lt;div&gt;&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Microsoft.Maui.Graphics&lt;/code&gt; does not implement SVG’s implicit-lineto rule — &lt;code dir=&quot;auto&quot;&gt;&quot;M6 6 18 18&quot;&lt;/code&gt; is a diagonal line in a
browser and &lt;em&gt;nothing at all&lt;/em&gt; on MAUI, because the parser reads the second pair as another moveto. It also cannot
read run-together decimals: &lt;code dir=&quot;auto&quot;&gt;l.06.06&lt;/code&gt; stops the parser dead and the rest of the path is dropped. Both forms are
common in exported artwork, and both render perfectly on Blazor and as a bare dot on MAUI. Write &lt;code dir=&quot;auto&quot;&gt;&quot;M6 6L18 18&quot;&lt;/code&gt;
and &lt;code dir=&quot;auto&quot;&gt;l.06 .06&lt;/code&gt;.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;
&lt;p&gt;On the web, &lt;code dir=&quot;auto&quot;&gt;prefers-reduced-motion: reduce&lt;/code&gt; is honoured automatically — the icon still renders and still
responds to clicks, it just holds its resting pose. An icon with no &lt;code dir=&quot;auto&quot;&gt;Title&lt;/code&gt; is marked &lt;code dir=&quot;auto&quot;&gt;aria-hidden&lt;/code&gt; so a screen
reader announces the button’s text once rather than twice.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;layer-3--shinybutton&quot;&gt;Layer 3 — ShinyButton&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Microsoft.Maui.Controls.Button&lt;/code&gt; renders text and one image. There is no way to put a spinner inside it, so the
most ordinary interaction in an app — press a button, wait for the network, see whether it worked — gets
hand-assembled on every page out of a &lt;code dir=&quot;auto&quot;&gt;Grid&lt;/code&gt;, an &lt;code dir=&quot;auto&quot;&gt;ActivityIndicator&lt;/code&gt;, a swapped label, and an &lt;code dir=&quot;auto&quot;&gt;IsBusy&lt;/code&gt; property on
the view model that exists purely for the UI’s benefit.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;ShinyButton&lt;/code&gt; is that assembly, done once, on both hosts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;MAUI&lt;/strong&gt; (iOS)&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Appearance × Type&lt;/th&gt;
&lt;th&gt;Motion icons in the slots&lt;/th&gt;
&lt;th&gt;All three busy modes at once&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/button/s1.png&quot; width=&quot;220&quot; alt=&quot;Appearance and Type combinations on MAUI&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/button/s2.png&quot; width=&quot;220&quot; alt=&quot;Motion icons in the leading and trailing slots&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/button/s3.png&quot; width=&quot;220&quot; alt=&quot;ReplaceLeftIcon, ReplaceContent, KeepContent and the native spinner, all busy&quot;&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Success state, command state&lt;/th&gt;
&lt;th&gt;Shapes &amp;#x26; sizes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/button/s4.png&quot; width=&quot;220&quot; alt=&quot;Success state, plus a button disabled by its consumer&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/button/s5.png&quot; width=&quot;220&quot; alt=&quot;Pill, square, small, large and full-width buttons&quot;&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Blazor&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Appearance × Type&lt;/th&gt;
&lt;th&gt;Motion icons&lt;/th&gt;
&lt;th&gt;Busy modes&lt;/th&gt;
&lt;th&gt;Success&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/button/blazor-s1.png&quot; width=&quot;220&quot; alt=&quot;Appearance and Type combinations on Blazor&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/button/blazor-s2.png&quot; width=&quot;220&quot; alt=&quot;Motion icons in the slots on Blazor&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/button/blazor-s3.png&quot; width=&quot;220&quot; alt=&quot;All busy modes on Blazor&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/button/blazor-s4.png&quot; width=&quot;220&quot; alt=&quot;Success state on Blazor&quot;&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div&gt;&lt;h3 id=&quot;nothing-binds-isbusy-because-there-is-no-isbusy&quot;&gt;Nothing binds IsBusy, because there is no IsBusy&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;!-- SaveCommand is an AsyncRelayCommand. That is the entire wiring. --&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;shiny:ShinyButton&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Text&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Save&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                   &lt;/span&gt;&lt;span&gt;BusyText&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Saving...&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                   &lt;/span&gt;&lt;span&gt;LeftMotionIcon&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;download&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                   &lt;/span&gt;&lt;span&gt;Command&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;{Binding SaveCommand}&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;ButtonState&lt;/code&gt; is &lt;code dir=&quot;auto&quot;&gt;Normal&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Busy&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Success&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;Error&lt;/code&gt;. Each non-normal state stands in its own text and its own
icon, and &lt;code dir=&quot;auto&quot;&gt;Success&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Error&lt;/code&gt; return to &lt;code dir=&quot;auto&quot;&gt;Normal&lt;/code&gt; on their own after &lt;code dir=&quot;auto&quot;&gt;StateRevertDelay&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;shiny:ShinyButton&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Text&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Submit&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                   &lt;/span&gt;&lt;span&gt;State&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;{Binding SubmitState}&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                   &lt;/span&gt;&lt;span&gt;BusyText&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Submitting...&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                   &lt;/span&gt;&lt;span&gt;SuccessText&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Submitted&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                   &lt;/span&gt;&lt;span&gt;ErrorText&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Failed&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                   &lt;/span&gt;&lt;span&gt;SuccessMotionIcon&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;check&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                   &lt;/span&gt;&lt;span&gt;ErrorMotionIcon&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;warning&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                   &lt;/span&gt;&lt;span&gt;StateRevertDelay&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;0:0:2&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                   &lt;/span&gt;&lt;span&gt;Command&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;{Binding SubmitCommand}&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Setting &lt;code dir=&quot;auto&quot;&gt;IsBusy&lt;/code&gt; false only unwinds &lt;code dir=&quot;auto&quot;&gt;Busy&lt;/code&gt; — it will &lt;strong&gt;not&lt;/strong&gt; cut a &lt;code dir=&quot;auto&quot;&gt;Success&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;Error&lt;/code&gt; short. That matters more
than it sounds: a view model clearing its busy flag in a &lt;code dir=&quot;auto&quot;&gt;finally&lt;/code&gt; block is exactly the moment the outcome is on
screen, and the naive projection would wipe the tick before anyone saw it.&lt;/p&gt;
&lt;p&gt;On Blazor there is no &lt;code dir=&quot;auto&quot;&gt;ICommand&lt;/code&gt;, so the equivalent is that &lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;Clicked&lt;/code&gt; is awaited&lt;/strong&gt; — an &lt;code dir=&quot;auto&quot;&gt;async&lt;/code&gt; handler holds
the button busy for exactly as long as it runs:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;ShinyButton&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Text&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Save&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BusyText&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Saving...&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;LeftMotionIcon&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;download&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Clicked&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;SaveAsync&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;@code&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;SaveAsync&lt;/span&gt;&lt;span&gt;() &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; http.&lt;/span&gt;&lt;span&gt;PostAsJsonAsync&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;/api/save&quot;&lt;/span&gt;&lt;span&gt;, model);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A &lt;strong&gt;synchronous&lt;/strong&gt; handler never flickers — the returned task is checked for completion before any state change,
so a handler that finished inline does not produce a one-frame spinner.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;motion-icons-in-the-slots&quot;&gt;Motion icons in the slots&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Each side takes an &lt;code dir=&quot;auto&quot;&gt;ImageSource&lt;/code&gt;, a motion icon name, or any &lt;code dir=&quot;auto&quot;&gt;View&lt;/code&gt;. The motion icon is the path worth taking:
the button colours it from its own resolved foreground and plays it from its own tap, so a tap anywhere on the
button animates the glyph rather than only one that lands on it.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;shiny:ShinyButton&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Text&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Refresh feed&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                   &lt;/span&gt;&lt;span&gt;LeftMotionIcon&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;refresh&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                   &lt;/span&gt;&lt;span&gt;RightMotionIcon&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;chevron-down&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                   &lt;/span&gt;&lt;span&gt;Appearance&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Outlined&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;This is exactly the case the motion-icon docs warn about: an icon inside a larger tap target should be
&lt;code dir=&quot;auto&quot;&gt;Manual&lt;/code&gt;, with the host playing it. &lt;code dir=&quot;auto&quot;&gt;ShinyButton&lt;/code&gt; sets that up on both hosts, and
&lt;code dir=&quot;auto&quot;&gt;MotionIconPlayOnClick=&quot;false&quot;&lt;/code&gt; turns it off.&lt;/p&gt;
&lt;p&gt;On Blazor the icons default to &lt;code dir=&quot;auto&quot;&gt;currentColor&lt;/code&gt;, so they inherit the button’s CSS &lt;code dir=&quot;auto&quot;&gt;color&lt;/code&gt; — including hover and
disabled — with nothing wired up at all.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;busy-modes-and-why-they-are-about-layout&quot;&gt;Busy modes, and why they are about layout&lt;/h3&gt;&lt;/div&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Behaviour&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;ReplaceLeftIcon&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;The indicator takes the left icon’s place and the text stays put. Both are &lt;code dir=&quot;auto&quot;&gt;IconSize&lt;/code&gt; square, so the button cannot change width and a row of buttons cannot reflow.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;ReplaceContent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The content fades to opacity zero — keeping its layout space — and a centred indicator takes over.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;KeepContent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The indicator appears after the right icon and nothing else moves.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;ReplaceContent&lt;/code&gt; uses opacity rather than visibility deliberately. Hiding the content would collapse the button
to the width of the spinner and shove the rest of the row sideways mid-operation; keeping it laid out but
invisible pins the width with no measuring on your part.&lt;/p&gt;
&lt;p&gt;The indicator is, in order: &lt;code dir=&quot;auto&quot;&gt;BusyIconView&lt;/code&gt; if you set one, a motion icon if &lt;code dir=&quot;auto&quot;&gt;BusyMotionIcon&lt;/code&gt; is set (default
&lt;code dir=&quot;auto&quot;&gt;loader&lt;/code&gt;), or a platform &lt;code dir=&quot;auto&quot;&gt;ActivityIndicator&lt;/code&gt; if you clear it.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;appearance-is-emphasis-type-is-meaning&quot;&gt;Appearance is emphasis, Type is meaning&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;They stay orthogonal on purpose — that is what lets a destructive action be loud (&lt;code dir=&quot;auto&quot;&gt;Filled&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;Critical&lt;/code&gt;) or quiet
(&lt;code dir=&quot;auto&quot;&gt;Text&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;Critical&lt;/code&gt;) without an enum member for every pairing. &lt;code dir=&quot;auto&quot;&gt;Appearance&lt;/code&gt; is &lt;code dir=&quot;auto&quot;&gt;Filled&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Tonal&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Outlined&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;Text&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;Elevated&lt;/code&gt;; &lt;code dir=&quot;auto&quot;&gt;Type&lt;/code&gt; is &lt;code dir=&quot;auto&quot;&gt;Primary&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Secondary&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Success&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Warning&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Critical&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;Info&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Everything resolves through the &lt;a href=&quot;https://www.shinylib.net/controls/theming/&quot;&gt;theme tokens&lt;/a&gt; — &lt;code dir=&quot;auto&quot;&gt;SetDynamicResource&lt;/code&gt; on MAUI,
&lt;code dir=&quot;auto&quot;&gt;--shiny-color-*&lt;/code&gt; custom properties on Blazor — so a live theme swap restyles a button with no re-render. Any
explicit colour short-circuits its token, which means it survives every theme change; leave them unset unless you
mean to pin the colour.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-command-integration-maui&quot;&gt;The command integration (MAUI)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Two behaviours, both on by default, and both with a sharper edge than they look:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;CanExecute&lt;/code&gt; → disabled&lt;/strong&gt; goes through MAUI’s own &lt;code dir=&quot;auto&quot;&gt;IsEnabledCore&lt;/code&gt; — the same mechanism
&lt;code dir=&quot;auto&quot;&gt;Microsoft.Maui.Controls.Button&lt;/code&gt; uses — rather than writing &lt;code dir=&quot;auto&quot;&gt;IsEnabled&lt;/code&gt;. A button that wrote &lt;code dir=&quot;auto&quot;&gt;IsEnabled&lt;/code&gt; would
overwrite your binding, and a command becoming executable again would silently re-enable a button you had
deliberately switched off.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;AutoBusy&lt;/code&gt;&lt;/strong&gt; solves the fact that &lt;code dir=&quot;auto&quot;&gt;ICommand.Execute&lt;/code&gt; returns &lt;code dir=&quot;auto&quot;&gt;void&lt;/code&gt;, so a button handed an async command has no
handle on the work it just started. Every async command implementation exposes an &lt;code dir=&quot;auto&quot;&gt;ExecutionTask&lt;/code&gt; or an
&lt;code dir=&quot;auto&quot;&gt;IsRunning&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;IsExecuting&lt;/code&gt; flag — MVVM Toolkit’s, Prism’s, ReactiveUI’s, most hand-rolled ones — but there is no
shared interface to type against. Rather than put an MVVM framework dependency into the core controls package
(landing it in every consumer’s app, whichever framework they actually use), the shape is discovered once per
command type and cached. Set &lt;code dir=&quot;auto&quot;&gt;AutoBusy=&quot;False&quot;&lt;/code&gt; and own &lt;code dir=&quot;auto&quot;&gt;State&lt;/code&gt; yourself for a fully trimmed or NativeAOT build.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;where-to-go-next&quot;&gt;Where to go next&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://shinyorg.github.io/controls/&quot;&gt;Blazor Playground&lt;/a&gt;&lt;/strong&gt; — motion icons and &lt;code dir=&quot;auto&quot;&gt;ShinyButton&lt;/code&gt;, live, right now.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/controls/keyframe/&quot;&gt;Keyframe&lt;/a&gt;&lt;/strong&gt; — &lt;a href=&quot;https://www.shinylib.net/controls/keyframe/xaml/&quot;&gt;XAML&lt;/a&gt;,
&lt;a href=&quot;https://www.shinylib.net/controls/keyframe/easing/&quot;&gt;easing&lt;/a&gt;, &lt;a href=&quot;https://www.shinylib.net/controls/keyframe/timelines/&quot;&gt;timelines &amp;#x26; playback&lt;/a&gt;,
&lt;a href=&quot;https://www.shinylib.net/controls/keyframe/scenes/&quot;&gt;drawn scenes&lt;/a&gt;, &lt;a href=&quot;https://www.shinylib.net/controls/keyframe/export/&quot;&gt;offscreen export&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/controls/motion-icons/&quot;&gt;Motion Icons&lt;/a&gt;&lt;/strong&gt; — &lt;a href=&quot;https://www.shinylib.net/controls/motion-icons/icons/&quot;&gt;the icon set&lt;/a&gt;,
&lt;a href=&quot;https://www.shinylib.net/controls/motion-icons/triggers/&quot;&gt;triggers&lt;/a&gt;, &lt;a href=&quot;https://www.shinylib.net/controls/motion-icons/presets/&quot;&gt;presets&lt;/a&gt;,
&lt;a href=&quot;https://www.shinylib.net/controls/motion-icons/custom/&quot;&gt;custom artwork&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/controls/button/&quot;&gt;ShinyButton&lt;/a&gt;&lt;/strong&gt; — &lt;a href=&quot;https://www.shinylib.net/controls/button/states/&quot;&gt;states &amp;#x26; commands&lt;/a&gt;,
&lt;a href=&quot;https://www.shinylib.net/controls/button/blazor/&quot;&gt;Blazor usage&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>controls</category><category>MAUI</category><category>Blazor</category><category>animation</category><category>release</category></item><item><title>Shiny Controls 1.0 — The Ultra Control Suite for .NET MAUI &amp; Blazor</title><link>https://www.shinylib.net/blog/2026/08/shiny-controls-1-0/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/08/shiny-controls-1-0/</guid><description>Sixty-odd controls, one token contract, two renderers, zero WebViews. Shiny Controls 1.0 ships the whole suite for .NET MAUI and Blazor — with a live Blazor playground you can click through right now.</description><pubDate>Mon, 17 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;Shiny Controls 1.0 is here.&lt;/strong&gt; One control suite, two renderers — native .NET MAUI and real Blazor
components — sharing a Material 3 style token contract so a &lt;code dir=&quot;auto&quot;&gt;ShinyButton&lt;/code&gt; on iOS and a &lt;code dir=&quot;auto&quot;&gt;ShinyButton&lt;/code&gt;
in the browser are the same control with the same API, not two lookalikes maintained in parallel.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Maui.Controls&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Maui.Controls?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.Maui.Controls&quot;&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Blazor.Controls&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Blazor.Controls?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.Blazor.Controls&quot;&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;click-it-before-you-install-it&quot;&gt;Click it before you install it&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The whole Blazor gallery is deployed and live. Every control below has a page in it, with the knobs
wired up so you can drive the thing rather than read about it:&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;-shinyorggithubiocontrols&quot;&gt;👉 &lt;a href=&quot;https://shinyorg.github.io/controls/&quot;&gt;&lt;strong&gt;shinyorg.github.io/controls&lt;/strong&gt;&lt;/a&gt;&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;It is the same &lt;code dir=&quot;auto&quot;&gt;Sample.Blazor&lt;/code&gt; project that lives in the repo, published to WebAssembly. The theme
selector in the header is not a demo affordance — it swaps the real theme pack stylesheet, and every
control on every page restyles itself from the tokens.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-catalogue&quot;&gt;The catalogue&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;There are too many controls to introduce one at a time, so here is the whole suite as a table. Each
one links to its own docs, which is where the properties, templates and platform notes live.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Controls&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Flagship&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/controls/tableview/&quot;&gt;TableView&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/scheduler/&quot;&gt;Scheduler&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/chatview/&quot;&gt;ChatView&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/imageeditor/&quot;&gt;ImageEditor&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Collections &amp;#x26; grids&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/controls/datagrid/&quot;&gt;DataGrid&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/virtualized-grid/&quot;&gt;VirtualizedGrid&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/staggered-grid/&quot;&gt;StaggeredGrid&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/parallax-collection-view/&quot;&gt;ParallaxCollectionView&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/carousel-gallery/&quot;&gt;CarouselGallery&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/carousel/&quot;&gt;Carousel&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Layout &amp;#x26; overlays&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/controls/layout/&quot;&gt;Stacks &amp;#x26; Grid&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/applayout/&quot;&gt;AppLayout&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/floatingpanel/&quot;&gt;FloatingPanel&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/sheetview/&quot;&gt;SheetView&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/overlay/&quot;&gt;Overlay&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/fab/&quot;&gt;Fab &amp;#x26; FabMenu&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/treeview/&quot;&gt;TreeView&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/frostedglass/&quot;&gt;FrostedGlassView&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/toolbar-tabbar/&quot;&gt;Toolbar &amp;#x26; TabBar&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/stateview/&quot;&gt;StateView&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/wizard/&quot;&gt;Wizard&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/walkthrough/&quot;&gt;Walkthrough&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/tooltip/&quot;&gt;Tooltip&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Input&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/controls/button/&quot;&gt;ShinyButton&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/textentry/&quot;&gt;TextEntry&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/autocomplete/&quot;&gt;AutoCompleteEntry&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/addressentry/&quot;&gt;AddressEntry&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/countrypicker/&quot;&gt;CountryPicker&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/colorpicker/&quot;&gt;ColorPicker&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/fontpicker/&quot;&gt;FontPicker&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/slider/&quot;&gt;Slider&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/rangeslider/&quot;&gt;RangeSlider&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/securitypin/&quot;&gt;SecurityPin&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/signaturepad/&quot;&gt;SignaturePad&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/media-picker-button/&quot;&gt;MediaPickerButton&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/durationpicker/&quot;&gt;DurationPicker&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/speech-addins/&quot;&gt;Speech Add-ins&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Display &amp;#x26; media&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/controls/cameraview/&quot;&gt;CameraView&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/mediaelement/&quot;&gt;MediaElement&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/shinyimage/&quot;&gt;ShinyImage&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/imageviewer/&quot;&gt;ImageViewer&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/markdown/&quot;&gt;Markdown&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/mermaid-diagrams/&quot;&gt;Mermaid Diagrams&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/barcodes/&quot;&gt;Barcodes &amp;#x26; QR&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/keyframe/&quot;&gt;Keyframe Animation&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/motion-icons/&quot;&gt;Motion Icons&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Status &amp;#x26; feedback&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/controls/toast/&quot;&gt;Toast&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/dialogs/&quot;&gt;Dialogs&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/progressbar/&quot;&gt;ProgressBar&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/skeleton/&quot;&gt;SkeletonView&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/splashscreen/&quot;&gt;Splash Screen&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/pillview/&quot;&gt;PillView&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/badge/&quot;&gt;BadgeView&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/feedback/&quot;&gt;Feedback Service&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Desktop&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/controls/trayicon/&quot;&gt;Tray Icon&lt;/a&gt; · &lt;a href=&quot;https://www.shinylib.net/controls/docking/&quot;&gt;Docking&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;System&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/controls/theming/&quot;&gt;Theming&lt;/a&gt; — the token contract and the Basic / Ocean / Material / Terminal / Aurora packs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Below are the three worth stopping on.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;chatview&quot;&gt;ChatView&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A full chat surface — bubbles, grouping, avatars, reactions, read receipts, typing indicators,
attachments, optimistic send with retry, cursor-based paging that stays stable while messages arrive.&lt;/p&gt;
&lt;p&gt;The thing that makes it different from a &lt;code dir=&quot;auto&quot;&gt;CollectionView&lt;/code&gt; with a bubble template is that &lt;strong&gt;it does not
bind a &lt;code dir=&quot;auto&quot;&gt;Messages&lt;/code&gt; collection.&lt;/strong&gt; You implement an &lt;code dir=&quot;auto&quot;&gt;IChatSessionProvider&lt;/code&gt; that hands the control a
session-scoped &lt;code dir=&quot;auto&quot;&gt;IChatSession&lt;/code&gt;, and the control subscribes to that session’s live events on attach and
disposes it on detach. Paging, live inserts, send verdicts and typing all flow through that one seam,
so the control owns the hard part instead of leaving it in your view model.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;shiny:ChatView&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Provider&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;{Binding Provider}&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                &lt;/span&gt;&lt;span&gt;SessionId&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;{Binding SessionId}&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                &lt;/span&gt;&lt;span&gt;MyBubbleColor&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;#DCF8C6&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                &lt;/span&gt;&lt;span&gt;OtherBubbleColor&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;White&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;partial&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ChatViewModel&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;IChatSessionProvider&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;provider&lt;/span&gt;&lt;span&gt;) : &lt;/span&gt;&lt;span&gt;ObservableObject&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;IChatSessionProvider&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Provider&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;; } &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; provider;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;SessionId&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;; } &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;demo&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That is the whole integration. Permissions drive what the UI offers — &lt;code dir=&quot;auto&quot;&gt;PermittedEmojis&lt;/code&gt; decides
whether the reaction row appears at all, &lt;code dir=&quot;auto&quot;&gt;BodyPermissions&lt;/code&gt; gates the markdown toolbar — so the
control never shows an affordance your backend will reject.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;MAUI&lt;/th&gt;
&lt;th&gt;MAUI — custom templates&lt;/th&gt;
&lt;th&gt;Blazor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/chatview/s1.png&quot; width=&quot;220&quot; alt=&quot;ChatView on MAUI with bubbles, avatars and the composer&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/chatview/s2.png&quot; width=&quot;220&quot; alt=&quot;ChatView with custom message templates&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/chatview/blazor-s1.png&quot; width=&quot;220&quot; alt=&quot;Bubbles, reactions and composer on Blazor&quot;&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;→ &lt;a href=&quot;https://www.shinylib.net/controls/chatview/&quot;&gt;ChatView docs&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;walkthrough&quot;&gt;Walkthrough&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Dim the page, cut an animated spotlight around one control at a time, say what it does. Onboarding,
feature announcements, and the workflow someone only does once a quarter.&lt;/p&gt;
&lt;p&gt;The design decision worth calling out: &lt;strong&gt;the steps live together on the walkthrough, in order&lt;/strong&gt; —
they are not attached properties on the controls they describe. On a real screen, with nested
layouts and templated cells and a panel that is only sometimes there, attached ordering scatters the
sequence across the markup where nothing can see it as a whole. Reordering becomes a hunt, and a step
whose control is conditionally hidden silently derails everything after it. Here, reordering is
moving a line, and &lt;code dir=&quot;auto&quot;&gt;IsVisible=&quot;False&quot;&lt;/code&gt; drops a step out of the run and re-numbers the counter.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;shiny:Walkthrough&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;RememberRunKey&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;home-v1&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AutoStart&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;True&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;OverlayOpacity&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;0.8&quot;&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&amp;#x3C;!-- No target: a centred welcome card, no cut-out. --&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;shiny:WalkthroughStep&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Title&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Welcome&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                           &lt;/span&gt;&lt;span&gt;Text&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Here is what is new in this release.&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                           &lt;/span&gt;&lt;span&gt;AnimationIn&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Pop&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;shiny:WalkthroughStep&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Target&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;{x:Reference SearchBox}&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                           &lt;/span&gt;&lt;span&gt;Title&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Find anything&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                           &lt;/span&gt;&lt;span&gt;Text&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Search across every project you can see.&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                           &lt;/span&gt;&lt;span&gt;Placement&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Bottom&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&amp;#x3C;!-- No card at all; the cut-out does the pointing. --&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;shiny:WalkthroughStep&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Target&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;{x:Reference Avatar}&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                           &lt;/span&gt;&lt;span&gt;Title&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Your profile&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                           &lt;/span&gt;&lt;span&gt;Text&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Settings and sign-out live here.&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                           &lt;/span&gt;&lt;span&gt;Display&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Spotlight&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                           &lt;/span&gt;&lt;span&gt;Highlight&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Circle&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&amp;#x3C;!-- Live control: the tap reaches it through the hole, and using it advances. --&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;shiny:WalkthroughStep&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Target&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;{x:Reference SaveButton}&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                           &lt;/span&gt;&lt;span&gt;Text&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Press Save to finish.&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                           &lt;/span&gt;&lt;span&gt;AllowTargetInteraction&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;True&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                           &lt;/span&gt;&lt;span&gt;AdvanceOnTargetTap&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;True&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;shiny:Walkthrough&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;RememberRunKey&lt;/code&gt; is what makes onboarding run &lt;em&gt;once&lt;/em&gt; — it is backed by a replaceable
&lt;code dir=&quot;auto&quot;&gt;IWalkthroughStore&lt;/code&gt; (&lt;code dir=&quot;auto&quot;&gt;Preferences&lt;/code&gt; on MAUI, &lt;code dir=&quot;auto&quot;&gt;localStorage&lt;/code&gt; on Blazor), and &lt;code dir=&quot;auto&quot;&gt;Restart()&lt;/code&gt; clears it. The
tour paints into a layer above the page content, so a target inside a scroll view or a card gets
highlighted where it actually is instead of being clipped by its container.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Welcome&lt;/th&gt;
&lt;th&gt;Popover on a target&lt;/th&gt;
&lt;th&gt;Circular spotlight&lt;/th&gt;
&lt;th&gt;Live target&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/walkthrough/s1.png&quot; width=&quot;200&quot; alt=&quot;A centred welcome card over the dimmed page on MAUI&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/walkthrough/s2.png&quot; width=&quot;200&quot; alt=&quot;The spotlight around the search box with a popover below it on MAUI&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/walkthrough/s3.png&quot; width=&quot;200&quot; alt=&quot;A circular cut-out around the avatar on MAUI&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/walkthrough/s4.png&quot; width=&quot;200&quot; alt=&quot;The Save button live through the cut-out on MAUI&quot;&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/walkthrough/blazor-s1.png&quot; width=&quot;200&quot; alt=&quot;The same welcome card on Blazor&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/walkthrough/blazor-s2.png&quot; width=&quot;200&quot; alt=&quot;The same search-box spotlight and popover on Blazor&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/walkthrough/blazor-s3.png&quot; width=&quot;200&quot; alt=&quot;The same circular avatar cut-out on Blazor&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/walkthrough/blazor-s4.png&quot; width=&quot;200&quot; alt=&quot;The same live Save button on Blazor&quot;&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;em&gt;Top row MAUI (iOS), bottom row Blazor — same four steps, same XAML-shaped markup.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;→ &lt;a href=&quot;https://www.shinylib.net/controls/walkthrough/&quot;&gt;Walkthrough docs&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;scheduler&quot;&gt;Scheduler&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Three views — a monthly calendar grid, a day/multi-day agenda timeline, and a vertically scrolling
event list — over &lt;strong&gt;one&lt;/strong&gt; data interface. You write the data layer once and pick the view per screen.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;MyEventProvider&lt;/span&gt;&lt;span&gt; : &lt;/span&gt;&lt;span&gt;ISchedulerEventProvider&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;IReadOnlyList&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;SchedulerEvent&lt;/span&gt;&lt;span&gt;&gt;&gt; &lt;/span&gt;&lt;span&gt;GetEvents&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;DateTimeOffset&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;start&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;DateTimeOffset&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;end&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; myService.&lt;/span&gt;&lt;span&gt;GetEventsAsync&lt;/span&gt;&lt;span&gt;(start, end);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;void&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;OnEventSelected&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;SchedulerEvent&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;selectedEvent&lt;/span&gt;&lt;span&gt;) { &lt;/span&gt;&lt;span&gt;/* navigate, show a sheet… */&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;bool&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;CanCalendarSelect&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;DateOnly&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;date&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;void&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;OnCalendarDateSelected&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;DateOnly&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;date&lt;/span&gt;&lt;span&gt;) { }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;void&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;OnAgendaTimeSelected&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;DateTimeOffset&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;time&lt;/span&gt;&lt;span&gt;) { }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;bool&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;CanSelectAgendaTime&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;DateTimeOffset&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;time&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;scheduler:SchedulerCalendarView&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Provider&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;{Binding Provider}&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                                 &lt;/span&gt;&lt;span&gt;SelectedDate&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;{Binding SelectedDate}&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Multi-day events span correctly across all three views, the agenda draws a live current-time marker
and supports extra timezone columns with sticky headers, the event list scrolls infinitely in both
directions, and every visual element — events, headers, loaders, day pickers — is replaceable with a
&lt;code dir=&quot;auto&quot;&gt;DataTemplate&lt;/code&gt;. Bindings use the static lambda overloads throughout, so it is AOT-safe with no
string-based reflection.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Calendar&lt;/th&gt;
&lt;th&gt;Agenda&lt;/th&gt;
&lt;th&gt;Event list&lt;/th&gt;
&lt;th&gt;Agenda + picker&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/scheduler/s1.png&quot; width=&quot;200&quot; alt=&quot;Monthly calendar grid&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/scheduler/s2.png&quot; width=&quot;200&quot; alt=&quot;Agenda timeline&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/scheduler/s3.png&quot; width=&quot;200&quot; alt=&quot;Event list&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/scheduler/s4.png&quot; width=&quot;200&quot; alt=&quot;Agenda with calendar picker&quot;&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;→ &lt;a href=&quot;https://www.shinylib.net/controls/scheduler/&quot;&gt;Scheduler docs&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;cameraview&quot;&gt;CameraView&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Screenshots of a camera control are famously useless — a picture of a preview is just a picture — so
here is what it actually does instead.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;CameraView&lt;/code&gt; is a cross-platform camera for MAUI (AVFoundation on iOS / Mac Catalyst / macOS,
CameraX on Android, Media Capture on Windows) with a matching Blazor WebAssembly control over
&lt;code dir=&quot;auto&quot;&gt;getUserMedia&lt;/code&gt;. Live preview, lens and device selection, pinch-to-zoom, torch, flash, photo capture
and video recording with quality/bitrate/frame-rate control are the table stakes.&lt;/p&gt;
&lt;p&gt;The two things that set it apart are &lt;strong&gt;pluggable pipelines&lt;/strong&gt;, and they compose with each other:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The frame-analysis pipeline.&lt;/strong&gt; Assign a single &lt;code dir=&quot;auto&quot;&gt;IFrameAnalyzer&lt;/code&gt; — declared right in XAML, since the
analyzer is the content property of &lt;code dir=&quot;auto&quot;&gt;CameraView&lt;/code&gt; — and frames stream to it off the UI thread with
drop-on-busy back-pressure. Bounding boxes draw &lt;em&gt;continuously&lt;/em&gt; via &lt;code dir=&quot;auto&quot;&gt;CameraOverlayView&lt;/code&gt;, but results
are delivered on a &lt;strong&gt;gated scan trigger&lt;/strong&gt;: arm with &lt;code dir=&quot;auto&quot;&gt;Scan()&lt;/code&gt; and the next confirmed detection fires
once. An optional &lt;code dir=&quot;auto&quot;&gt;ScanWindow&lt;/code&gt; restricts detection to a region and draws an aim reticle. Built-in
analyzers cover barcode/QR (native Vision and MLKit, restrictable by symbology), face detection with
landmarks, motion clustered into debounced regions, OCR with scan-window crop and upscale for small
text, and &lt;strong&gt;structured documents&lt;/strong&gt; — invoices with order lines, receipts with line items and per-tax
breakdowns, business cards, AAMVA driver’s licences, province-aware Canadian health cards, credit
cards and passport MRZ — each a strong record with nullable fields. When the document is free-form,
&lt;code dir=&quot;auto&quot;&gt;AiDocumentAnalyzer&amp;#x3C;T&gt;&lt;/code&gt; detects presence cheaply on every frame and sends exactly one frame to a
&lt;code dir=&quot;auto&quot;&gt;Microsoft.Extensions.AI&lt;/code&gt; &lt;code dir=&quot;auto&quot;&gt;IChatClient&lt;/code&gt; for structured extraction.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The effects pipeline.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;Effects&lt;/code&gt; is an ordered, live collection applied to the preview, captured
stills and — on Apple — recorded video. Mutate it while the camera runs and the change lands on the
next frame. Eleven colour grades, five spatial GPU looks (comic, sketch, posterize, pixelate, blur),
compositing draw effects for watermarks and &lt;strong&gt;face masks anchored to tracked facial landmarks&lt;/strong&gt;, and
slow post-capture transforms such as AI photo stylization through an MEAI &lt;code dir=&quot;auto&quot;&gt;IImageGenerator&lt;/code&gt;. Four
extension points let you add your own at the right layer. And because per-platform coverage is
genuinely uneven, &lt;code dir=&quot;auto&quot;&gt;GetEffectSupport(effect)&lt;/code&gt; reports &lt;code dir=&quot;auto&quot;&gt;Full&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;ColorOnly&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;StillOnly&lt;/code&gt; /
&lt;code dir=&quot;auto&quot;&gt;Unsupported&lt;/code&gt; so your UI can grey out what would otherwise silently do nothing.&lt;/p&gt;
&lt;p&gt;Two more that are easy to miss: you can &lt;strong&gt;record and analyse at the same time&lt;/strong&gt; on every platform — a
dash-cam app reading signs off its own live feed while recording — and
&lt;code dir=&quot;auto&quot;&gt;VideoRecordingOptions.Overlay&lt;/code&gt; burns a watermark, timestamp or telemetry into every &lt;em&gt;encoded&lt;/em&gt; frame,
drawn with &lt;code dir=&quot;auto&quot;&gt;Microsoft.Maui.Graphics&lt;/code&gt; so one implementation covers every platform.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Maui.Controls.Camera&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Maui.Controls.Camera?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.Maui.Controls.Camera&quot;&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Blazor.Controls.Camera&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Blazor.Controls.Camera?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.Blazor.Controls.Camera&quot;&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;→ &lt;a href=&quot;https://www.shinylib.net/controls/cameraview/&quot;&gt;CameraView docs&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;theming-ties-it-together&quot;&gt;Theming ties it together&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;None of the above is styled by hand. Colour roles, surfaces, shape, elevation, typography, density,
borders, state and spacing are a &lt;strong&gt;token contract&lt;/strong&gt; the controls read — &lt;code dir=&quot;auto&quot;&gt;SetDynamicResource&lt;/code&gt; on MAUI,
&lt;code dir=&quot;auto&quot;&gt;var(--shiny-*)&lt;/code&gt; on Blazor. The core packages define the contract and a built-in &lt;strong&gt;Basic&lt;/strong&gt; theme;
&lt;strong&gt;Ocean&lt;/strong&gt;, &lt;strong&gt;Material&lt;/strong&gt;, &lt;strong&gt;Terminal&lt;/strong&gt; and &lt;strong&gt;Aurora&lt;/strong&gt; install as separate NuGet packs and swap the
whole app’s look without touching a page.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Basic&lt;/th&gt;
&lt;th&gt;Ocean&lt;/th&gt;
&lt;th&gt;Material&lt;/th&gt;
&lt;th&gt;Terminal&lt;/th&gt;
&lt;th&gt;Aurora&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/theming/s1.png&quot; width=&quot;150&quot; alt=&quot;The button gallery under the Basic theme&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/theming/s2.png&quot; width=&quot;150&quot; alt=&quot;Under the Ocean pack&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/theming/s3.png&quot; width=&quot;150&quot; alt=&quot;Under the Material pack&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/theming/s4.png&quot; width=&quot;150&quot; alt=&quot;Under the Terminal pack&quot;&gt;&lt;/td&gt;
&lt;td&gt;&lt;img src=&quot;https://www.shinylib.net/images/theming/s5.png&quot; width=&quot;150&quot; alt=&quot;Under the Aurora pack&quot;&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Want your own? The &lt;a href=&quot;https://www.shinylib.net/controls/theming/creator&quot;&gt;Theme Creator&lt;/a&gt; takes a few seed colours and exports
the theme JSON, the Blazor CSS, or the MAUI C#.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;getting-started&quot;&gt;Getting started&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.Maui.Controls&lt;/span&gt;&lt;span&gt;     &lt;/span&gt;&lt;span&gt;# .NET MAUI&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.Blazor.Controls&lt;/span&gt;&lt;span&gt;   &lt;/span&gt;&lt;span&gt;# Blazor&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Then go press things in the &lt;a href=&quot;https://shinyorg.github.io/controls/&quot;&gt;&lt;strong&gt;playground&lt;/strong&gt;&lt;/a&gt;, and see the
&lt;a href=&quot;https://www.shinylib.net/controls/&quot;&gt;controls documentation&lt;/a&gt; for the rest.&lt;/p&gt;</content:encoded><category>controls</category><category>MAUI</category><category>Blazor</category><category>release</category></item><item><title>Shiny.DocumentDb — Hidden Gems</title><link>https://www.shinylib.net/blog/2026/08/documentdb-hidden-gems/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/08/documentdb-hidden-gems/</guid><description>Thirteen major versions in, DocumentDb has accumulated a lot of API that never made a headline — a store that can diff a document for you, a read path that never deserializes, and an extensibility surface that soft delete is built out of. Here&apos;s what you already paid for.</description><pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.DocumentDb&quot; alt=&quot;NuGet package Shiny.DocumentDb&quot;&gt;&lt;/a&gt;
&lt;p&gt;Every release post covers the headline. Thirteen major versions of those leaves a lot of API that shipped
in a bullet list and never got explained — which is a shame, because some of it is the stuff I actually
reach for most.&lt;/p&gt;
&lt;p&gt;This post is the sweep. Three features get real depth because they change how you’d write the code around
them, and the rest are quick hits. Nothing here is new in v13; all of it is in the box today.&lt;/p&gt;
&lt;aside aria-label=&quot;Coming from v12?&quot;&gt;&lt;p aria-hidden=&quot;true&quot;&gt;Coming from v12?&lt;/p&gt;&lt;div&gt;&lt;p&gt;Per-type configuration moved to a single &lt;code dir=&quot;auto&quot;&gt;ConfigureDocument&amp;#x3C;T&gt;&lt;/code&gt; block in v13 — &lt;code dir=&quot;auto&quot;&gt;MapTemporal&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;AddQueryFilter&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MapComputedProperty&lt;/code&gt; and friends are all methods on &lt;code dir=&quot;auto&quot;&gt;cfg&lt;/code&gt; now. The snippets below use the
v13 spelling. See &lt;a href=&quot;https://www.shinylib.net/documentdb/migrating-v12-v13/&quot;&gt;Migrating v12 → v13&lt;/a&gt;.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;1-the-store-already-knows-what-changed&quot;&gt;1. The store already knows what changed&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;You have a document in the database and an object in memory that’s been edited by a form, a merge, or an
LLM. The question every app asks next is &lt;em&gt;what actually changed?&lt;/em&gt; — for an audit line, for a
confirmation screen, for an “are you sure, this touches 4 fields” dialog.&lt;/p&gt;
&lt;p&gt;The usual answer is to write a comparer. You don’t have to:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;patch&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;GetDiff&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(order.Id, edited);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// JsonPatchDocument&amp;#x3C;Order&gt;, RFC 6902 — null when no such document exists&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;foreach&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;op&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; patch&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;.Operations)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;logger.&lt;/span&gt;&lt;span&gt;LogInformation&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;{Op} {Path} =&gt; {Value}&quot;&lt;/span&gt;&lt;span&gt;, op.Op, op.Path, op.Value);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;GetDiff&lt;/code&gt; reads the stored document, compares it against the candidate, and hands back a
&lt;code dir=&quot;auto&quot;&gt;JsonPatchDocument&amp;#x3C;T&gt;&lt;/code&gt; — a real RFC 6902 patch, not a string. Note the shape of it: this is a &lt;strong&gt;read&lt;/strong&gt;. You
haven’t written anything yet, so it’s the thing to call &lt;em&gt;before&lt;/em&gt; the save, which is exactly when the
question gets asked.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;writing-only-what-changed&quot;&gt;Writing only what changed&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The write side has the same idea from the other direction. A full &lt;code dir=&quot;auto&quot;&gt;Update&lt;/code&gt; replaces the stored body; an
&lt;code dir=&quot;auto&quot;&gt;Upsert&lt;/code&gt; deep-merges it, RFC 7396:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// only the non-null properties are touched; everything else stays as stored&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Upsert&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt; { Id &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; id, Status &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;shipped&quot;&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s the default behaviour of &lt;code dir=&quot;auto&quot;&gt;Upsert&lt;/code&gt; on every provider. Its inverse — replace-on-update rather than
merge — is &lt;code dir=&quot;auto&quot;&gt;Upsert(patch, patchIfUpdate: false)&lt;/code&gt;, which the relational providers implement and the rest
refuse rather than fake. There’s a matching &lt;code dir=&quot;auto&quot;&gt;Update(document, patch: true)&lt;/code&gt; when the document must already
exist.&lt;/p&gt;
&lt;p&gt;For a genuine partial update through a typed object, remember the type has to be able to &lt;em&gt;express&lt;/em&gt;
“unset”: &lt;code dir=&quot;auto&quot;&gt;JsonIgnoreCondition.WhenWritingNull&lt;/code&gt; on the properties, or use the
&lt;a href=&quot;https://www.shinylib.net/documentdb/json-collections/&quot;&gt;JSON collection lane&lt;/a&gt; where the body is a &lt;code dir=&quot;auto&quot;&gt;JsonObject&lt;/code&gt; and absent means
absent.&lt;/p&gt;
&lt;p&gt;And when it’s one field, skip the object entirely:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;SetProperty&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(id, &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.Status, &lt;/span&gt;&lt;span&gt;&quot;shipped&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;RemoveProperty&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(id, &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.CancelReason);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Both return &lt;code dir=&quot;auto&quot;&gt;bool&lt;/code&gt; — &lt;code dir=&quot;auto&quot;&gt;false&lt;/code&gt; when no such document — and both are a single statement against the JSON
column. No read, no round trip, no lost update from the write you didn’t know was concurrent with yours.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;then-turn-on-temporal-and-it-gets-silly&quot;&gt;Then turn on temporal and it gets silly&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Map a type as temporal and every one of those writes leaves a version behind:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;options.&lt;/span&gt;&lt;span&gt;ConfigureDocument&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;cfg&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; cfg.&lt;/span&gt;&lt;span&gt;MapTemporal&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.Retention &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; TimeSpan.&lt;/span&gt;&lt;span&gt;FromDays&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;90&lt;/span&gt;&lt;span&gt;)));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;temporal&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;ITemporalDocumentStore&lt;/span&gt;&lt;span&gt;)store;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; temporal.&lt;/span&gt;&lt;span&gt;History&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(id);                          &lt;/span&gt;&lt;span&gt;// every version&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; temporal.&lt;/span&gt;&lt;span&gt;AsOf&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(id, lastTuesday);                &lt;/span&gt;&lt;span&gt;// the document as it was&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; temporal.&lt;/span&gt;&lt;span&gt;ChangesByActor&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;&quot;user:42&quot;&lt;/span&gt;&lt;span&gt;);            &lt;/span&gt;&lt;span&gt;// everything one actor touched&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; temporal.&lt;/span&gt;&lt;span&gt;GetDiffBetween&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(id, &lt;/span&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;7&lt;/span&gt;&lt;span&gt;);             &lt;/span&gt;&lt;span&gt;// a patch between two versions&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; temporal.&lt;/span&gt;&lt;span&gt;Restore&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(id, &lt;/span&gt;&lt;span&gt;version&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt;);              &lt;/span&gt;&lt;span&gt;// put it back&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s an audit trail, a point-in-time read, a per-user change log and an undo button, from one line of
configuration. It works on every provider — relational, Cosmos, MongoDB, LiteDB, IndexedDB — through
&lt;code dir=&quot;auto&quot;&gt;ITemporalDocumentStore&lt;/code&gt;, which you probe for rather than assume (&lt;code dir=&quot;auto&quot;&gt;store is ITemporalDocumentStore&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;The reason I keep pointing at this one: almost everybody hand-rolls a &lt;code dir=&quot;auto&quot;&gt;ChangeLog&lt;/code&gt; table, and it’s almost
always worse than this, because a hand-rolled one records the fields somebody remembered to record.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;2-stop-deserializing-json-just-to-serialize-it-again&quot;&gt;2. Stop deserializing JSON just to serialize it again&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Here’s the shape of an enormous number of API endpoints:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;app.&lt;/span&gt;&lt;span&gt;MapGet&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;/orders/{id}&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;IDocumentStore&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;order&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Get&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(id);   &lt;/span&gt;&lt;span&gt;// JSON -&gt; Order&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; Results.&lt;/span&gt;&lt;span&gt;Ok&lt;/span&gt;&lt;span&gt;(order);                 &lt;/span&gt;&lt;span&gt;// Order -&gt; JSON&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The database handed you a perfectly good JSON document. You parsed it into an object graph, allocated
every string and list in it, and then serialized it straight back into bytes that are — modulo whitespace
— what you started with. &lt;code dir=&quot;auto&quot;&gt;Order&lt;/code&gt; did no work. It was overhead with a type name.&lt;/p&gt;
&lt;p&gt;DocumentDb stores JSON. So take JSON:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;app.&lt;/span&gt;&lt;span&gt;MapGet&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;/orders/{id}&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;IDocumentStore&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;raw&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;().&lt;/span&gt;&lt;span&gt;Where&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.Id &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; id).&lt;/span&gt;&lt;span&gt;FirstOrDefaultRawJson&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; raw &lt;/span&gt;&lt;span&gt;is&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;?&lt;/span&gt;&lt;span&gt; Results.&lt;/span&gt;&lt;span&gt;NotFound&lt;/span&gt;&lt;span&gt;() &lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; Results.&lt;/span&gt;&lt;span&gt;Content&lt;/span&gt;&lt;span&gt;(raw, &lt;/span&gt;&lt;span&gt;&quot;application/json&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;And for a list, don’t even materialize the list — stream it into the response as it comes off the reader:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;ctx.Response.ContentType &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;application/json&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Where&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.Status &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;open&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;OrderByDescending&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.CreatedAt)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WriteJsonArrayTo&lt;/span&gt;&lt;span&gt;(ctx.Response.Body, ct);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The point that makes this usable rather than a curiosity: &lt;strong&gt;you still build the query with the typed
surface.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;Where&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;OrderBy&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Paginate&lt;/code&gt;, global query filters, soft delete, tenancy — all of it applies,
because only the &lt;em&gt;terminal&lt;/em&gt; changed. You get the compiler checking your predicate and the database
handing back bytes.&lt;/p&gt;
&lt;p&gt;There’s a node lane too, when you want to touch the JSON before it leaves: &lt;code dir=&quot;auto&quot;&gt;ToJsonList&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;ToJsonAsyncEnumerable&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;FirstJson&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;FirstOrDefaultJson&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;SingleJson&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;SingleOrDefaultJson&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;ToJsonCursorPage&lt;/code&gt; — all returning &lt;code dir=&quot;auto&quot;&gt;JsonObject&lt;/code&gt;, all built on the same &lt;code dir=&quot;auto&quot;&gt;RawJsonRows&lt;/code&gt; primitive the raw
lane uses.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;the-fidelity-rules-because-they-matter&quot;&gt;The fidelity rules, because they matter&lt;/h3&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;On the relational providers and Cosmos DB, these are the &lt;strong&gt;persisted bytes, untouched&lt;/strong&gt;. Zero parses.&lt;/li&gt;
&lt;li&gt;Everywhere else the provider has to materialize &lt;code dir=&quot;auto&quot;&gt;T&lt;/code&gt; to finish the query, so the body is re-serialized
through the type’s &lt;code dir=&quot;auto&quot;&gt;JsonTypeInfo&lt;/code&gt;. Same JSON, same API — but the round trip is real, and you should
expect no win.&lt;/li&gt;
&lt;li&gt;Materialized computed properties live outside the body, so they don’t appear. A &lt;code dir=&quot;auto&quot;&gt;DocumentBlob&lt;/code&gt; shows up
as its metadata envelope, not its payload.&lt;/li&gt;
&lt;li&gt;A type with &lt;strong&gt;encrypted properties throws&lt;/strong&gt;. The stored body is ciphertext, and only the typed
terminals decrypt.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last group is why there’s a &lt;code dir=&quot;auto&quot;&gt;SupportsRawJson&lt;/code&gt; flag on the query. Test it rather than catching the
throw when the JSON lane is an optimization and the typed path is still correct — which is exactly how the
built-in OData and AI surfaces pick a lane.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;trim-the-fields-on-the-way-out&quot;&gt;Trim the fields on the way out&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Pair it with the string projection when the caller only wants some of the document:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// a REST ?fields= sparse fieldset, resolved at runtime&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;rows&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Project&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;id, number, total, customer.name as customer, lower(status) as status&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ToJsonList&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Dotted paths reach into nested objects and become first-class output keys. Scalar functions from the
string grammar (&lt;code dir=&quot;auto&quot;&gt;lower&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;length&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;substring&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;year&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;soundex&lt;/code&gt;, …) can be projected too, and require an
alias. Relational providers do this in SQL; the document providers do it client-side.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;3-everything-soft-delete-taught-me-about-extensibility&quot;&gt;3. Everything soft delete taught me about extensibility&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Soft delete shipped in v12 as a one-liner:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;options.&lt;/span&gt;&lt;span&gt;ConfigureDocument&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Customer&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;cfg&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; cfg.&lt;/span&gt;&lt;span&gt;AddSoftDelete&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.IsDeleted));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Deletes set the flag instead of deleting, and every read hides flagged documents. The part worth writing
about is that &lt;strong&gt;nothing in any store knows it exists.&lt;/strong&gt; Here is, essentially, the whole implementation:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;options.&lt;/span&gt;&lt;span&gt;AddInterceptor&lt;/span&gt;&lt;span&gt;(interceptor);       &lt;/span&gt;&lt;span&gt;// cancel the delete, set the flag instead&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;options.&lt;/span&gt;&lt;span&gt;AddBulkInterceptor&lt;/span&gt;&lt;span&gt;(interceptor);   &lt;/span&gt;&lt;span&gt;// same for ExecuteDelete / Clear&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;options.Mappings.&lt;/span&gt;&lt;span&gt;AddQueryFilter&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;soft-delete&quot;&lt;/span&gt;&lt;span&gt;, mapping.NotDeleted);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Three public calls. No provider changes, no &lt;code dir=&quot;auto&quot;&gt;if (softDelete)&lt;/code&gt; anywhere in the query pipeline, and it
works identically on all twenty-odd backends because it never went near one.&lt;/p&gt;
&lt;p&gt;The two primitives it’s made of are worth knowing on their own.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;ctxcancel--replace-a-write-dont-just-watch-it&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;ctx.Cancel()&lt;/code&gt; — replace a write, don’t just watch it&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;An interceptor’s &lt;code dir=&quot;auto&quot;&gt;BeforeWrite&lt;/code&gt; can &lt;em&gt;substitute&lt;/em&gt; itself for the write:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ArchiveOnDelete&lt;/span&gt;&lt;span&gt; : &lt;/span&gt;&lt;span&gt;IDocumentInterceptor&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BeforeWrite&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;DocumentWriteContext&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;CancellationToken&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ct&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (ctx.Operation &lt;/span&gt;&lt;span&gt;!=&lt;/span&gt;&lt;span&gt; DocumentOperation.Delete &lt;/span&gt;&lt;span&gt;||&lt;/span&gt;&lt;span&gt; ctx.DocumentType &lt;/span&gt;&lt;span&gt;!=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;typeof&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;order&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; ctx.Store.&lt;/span&gt;&lt;span&gt;Get&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(ctx.Id&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;cancellationToken&lt;/span&gt;&lt;span&gt;: ct);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (order &lt;/span&gt;&lt;span&gt;!=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; ctx.Session&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Add&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ArchivedOrder&lt;/span&gt;&lt;span&gt; { Id &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; order.Id, Body &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; order, ArchivedAt &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; DateTimeOffset.UtcNow })&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;SaveChanges&lt;/span&gt;&lt;span&gt;(ct);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;ctx.&lt;/span&gt;&lt;span&gt;Cancel&lt;/span&gt;&lt;span&gt;();   &lt;/span&gt;&lt;span&gt;// the store performs no delete, and reports success to the caller&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AfterWrite&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;DocumentWriteContext&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;CancellationToken&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ct&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; Task.CompletedTask;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Cancel()&lt;/code&gt; means the store does nothing, no &lt;code dir=&quot;auto&quot;&gt;AfterWrite&lt;/code&gt; runs, and no change notification is published —
the caller gets the outcome you name (&lt;code dir=&quot;auto&quot;&gt;Cancel(succeeded: false)&lt;/code&gt; reports failure). It’s only legal inside
&lt;code dir=&quot;auto&quot;&gt;BeforeWrite&lt;/code&gt;; calling it later throws rather than silently doing nothing. &lt;code dir=&quot;auto&quot;&gt;ctx.Session&lt;/code&gt; is scoped to the
write’s own transaction, so the archive row commits with the operation that caused it, or not at all.&lt;/p&gt;
&lt;p&gt;That’s an append-only archive, in about twenty lines, that no provider needed to hear about.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;named-query-filters--and-lifting-them-one-query-at-a-time&quot;&gt;Named query filters — and lifting them one query at a time&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;A global filter usually gets registered anonymously and then becomes a problem the first time an admin
screen needs to see past it. Give it a name:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;options.&lt;/span&gt;&lt;span&gt;ConfigureDocument&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;cfg&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; cfg.&lt;/span&gt;&lt;span&gt;AddQueryFilter&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;archived&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;o.IsArchived));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;store.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;().&lt;/span&gt;&lt;span&gt;IgnoreQueryFilters&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;archived&quot;&lt;/span&gt;&lt;span&gt;);   &lt;/span&gt;&lt;span&gt;// just this one&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;store.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;().&lt;/span&gt;&lt;span&gt;IgnoreQueryFilters&lt;/span&gt;&lt;span&gt;();             &lt;/span&gt;&lt;span&gt;// all of them&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Which is exactly what soft delete’s own &lt;code dir=&quot;auto&quot;&gt;IncludeDeleted()&lt;/code&gt; does — it’s a one-line extension over
&lt;code dir=&quot;auto&quot;&gt;IgnoreQueryFilters(SoftDelete.FilterName)&lt;/code&gt;. Features here are meant to be built this way: extension
methods over public hooks, so an optional feature never becomes a member on an options class that every
provider has to carry.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;quick-hits&quot;&gt;Quick hits&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;ToQueryString()&lt;/code&gt;&lt;/strong&gt; — see what your LINQ actually became, without running it.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;q&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;().&lt;/span&gt;&lt;span&gt;Where&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.Total &lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;100&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;OrderBy&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.CreatedAt).&lt;/span&gt;&lt;span&gt;ToQueryString&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Console.&lt;/span&gt;&lt;span&gt;WriteLine&lt;/span&gt;&lt;span&gt;(q.Sql);          &lt;/span&gt;&lt;span&gt;// the provider&apos;s SQL (or MongoDB&apos;s rendered BSON)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Console.&lt;/span&gt;&lt;span&gt;WriteLine&lt;/span&gt;&lt;span&gt;(q.Parameters);   &lt;/span&gt;&lt;span&gt;// the bound values&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Relational providers and Cosmos return SQL; MongoDB returns its filter as JSON; the in-memory evaluators
(LiteDB, IndexedDB) throw, because there’s nothing to show.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cursor pagination&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;Skip&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Take&lt;/code&gt; gets slower the deeper you go and shifts under concurrent writes.
Keyset paging doesn’t:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;CursorPage&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt; &lt;/span&gt;&lt;span&gt;page&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Where&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.Status &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;open&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;OrderByDescending&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.CreatedAt)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ToCursorPage&lt;/span&gt;&lt;span&gt;(cursor, &lt;/span&gt;&lt;span&gt;take&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;50&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;page.Items;        &lt;/span&gt;&lt;span&gt;// this page&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;page.NextCursor;   &lt;/span&gt;&lt;span&gt;// opaque token; null means that was the last page&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;page.HasMore;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;O(log n) per page with an index on the sort key, an &lt;code dir=&quot;auto&quot;&gt;Id&lt;/code&gt; tiebreaker appended for you, and a shape hash so
a cursor can’t be replayed against a differently-filtered query. &lt;code dir=&quot;auto&quot;&gt;ToJsonCursorPage&lt;/code&gt; is the same thing in
the JSON lane. There’s no total count — that’s what &lt;code dir=&quot;auto&quot;&gt;Paginate&lt;/code&gt; is for.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;DocumentFunctions.Soundex&lt;/code&gt;&lt;/strong&gt; — fuzzy name matching that pushes down to the engine:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;store.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Person&lt;/span&gt;&lt;span&gt;&gt;().&lt;/span&gt;&lt;span&gt;Where&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; DocumentFunctions.&lt;/span&gt;&lt;span&gt;Soundex&lt;/span&gt;&lt;span&gt;(p.Name) &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; DocumentFunctions.&lt;/span&gt;&lt;span&gt;Soundex&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Smith&quot;&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Native &lt;code dir=&quot;auto&quot;&gt;SOUNDEX()&lt;/code&gt; on SQL Server and MySQL, &lt;code dir=&quot;auto&quot;&gt;fuzzystrmatch&lt;/code&gt; on PostgreSQL, a registered UDF where there’s
nothing built in.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;NotifyOnChange()&lt;/code&gt;&lt;/strong&gt; — a change feed scoped to &lt;em&gt;one query&lt;/em&gt;, as an &lt;code dir=&quot;auto&quot;&gt;IAsyncEnumerable&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;foreach&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;change&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;().&lt;/span&gt;&lt;span&gt;Where&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.Status &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;open&quot;&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;NotifyOnChange&lt;/span&gt;&lt;span&gt;(ct))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Console.&lt;/span&gt;&lt;span&gt;WriteLine&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;$&quot;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;change&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ChangeType&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;change&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Id&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;IDocumentSeeder&lt;/code&gt;&lt;/strong&gt; — versioned, provider-agnostic seed data with a marker so it runs once:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ProductSeeder&lt;/span&gt;&lt;span&gt; : &lt;/span&gt;&lt;span&gt;IDocumentSeeder&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Name&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;products&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Version&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;3&lt;/span&gt;&lt;span&gt;;                    &lt;/span&gt;&lt;span&gt;// bump to re-run&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;SeedAsync&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;IDocumentStore&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;CancellationToken&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ct&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;BatchInsert&lt;/span&gt;&lt;span&gt;(Products, &lt;/span&gt;&lt;span&gt;cancellationToken&lt;/span&gt;&lt;span&gt;: ct);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;services.&lt;/span&gt;&lt;span&gt;AddDocumentSeeder&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;ProductSeeder&lt;/span&gt;&lt;span&gt;&gt;();    &lt;/span&gt;&lt;span&gt;// runs at startup&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;JSON Schema validation&lt;/strong&gt; (&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.JsonSchema&lt;/code&gt;) — draft 2020-12, checked against the exact bytes
about to hit disk:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;options.&lt;/span&gt;&lt;span&gt;ConfigureDocument&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;cfg&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; cfg.&lt;/span&gt;&lt;span&gt;MapJsonSchemaFromFile&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;schemas/order.json&quot;&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Schema-free doesn’t have to mean unvalidated, and it’s per type — validate the two documents that matter
and leave the rest open.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Computed properties&lt;/strong&gt; — a value derived from other fields that you can still filter and sort on:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;options.&lt;/span&gt;&lt;span&gt;ConfigureDocument&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;OrderLine&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;cfg&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cfg.&lt;/span&gt;&lt;span&gt;MapComputedProperty&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.LineTotal, &lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.Quantity &lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt; x.UnitPrice, &lt;/span&gt;&lt;span&gt;indexed&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;indexed: true&lt;/code&gt; asks for a materialized, indexable computed column where the backend has one; without it,
it’s an alias expanded into the query.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;IDocumentMaintenance&lt;/code&gt;&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;ClearAll()&lt;/code&gt; wipes every type including temporal, spatial and vector sidecars
(tests and dev resets, not tenant-scoped), and &lt;code dir=&quot;auto&quot;&gt;SweepOrphanedBlobs&amp;#x3C;T&gt;()&lt;/code&gt; collects blob rows whose owning
document went away out of band. Probe for it: &lt;code dir=&quot;auto&quot;&gt;store is IDocumentMaintenance&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;and-the-tools-nobodys-seen&quot;&gt;And the tools nobody’s seen&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The two most under-advertised things in the project aren’t API at all. &lt;strong&gt;ShinyDocDbMyAdmin&lt;/strong&gt; has had a
terminal front end since v12.5 — the same tool as the web UI, as a &lt;code dir=&quot;auto&quot;&gt;dotnet tool&lt;/code&gt;, over SSH — and the web
one has shipped as a &lt;a href=&quot;https://www.shinylib.net/documentdb/admin/docker-desktop&quot;&gt;Docker Desktop extension&lt;/a&gt; since 13.0.1, which
hands it every database container already running on your machine, connected.&lt;/p&gt;
&lt;p&gt;Both deserve their own post with screenshots, and they’re getting one. In the meantime:
&lt;a href=&quot;https://www.shinylib.net/documentdb/admin/&quot;&gt;the admin docs&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>documentdb</category><category>features</category></item><item><title>Shiny.DocumentDb v13 — Encryption, an Outbox, and a Front Door</title><link>https://www.shinylib.net/blog/2026/08/documentdb-v13/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/08/documentdb-v13/</guid><description>v13 puts AES-256-GCM field encryption in the core package, adds a transactional outbox that commits with the write that caused it, and gives a document type a front door — REST + Server-Sent Events, and an MCP server. Per-type configuration collapses into one ConfigureDocument block, Orleans gets durable streams on the database it already uses, and the admin tools learn to read an encrypted store without a key.</description><pubDate>Wed, 12 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.DocumentDb&quot; alt=&quot;NuGet package Shiny.DocumentDb&quot;&gt;&lt;/a&gt;
&lt;p&gt;v13 is the release where DocumentDb stopped being only a place to put documents and became something you
can put &lt;em&gt;in front of&lt;/em&gt; things — an HTTP resource, an MCP endpoint, an Orleans stream provider, an outbox
that commits with the write that caused it. And underneath all of that, the data can now be encrypted at
rest, field by field, on every provider.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/encryption/&quot;&gt;Field-level encryption&lt;/a&gt;&lt;/strong&gt; — AES-256-GCM, in the core package, no new
dependency. Deterministic mode keeps equality filters working.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/outbox/&quot;&gt;Transactional outbox&lt;/a&gt;&lt;/strong&gt; — record “this happened” in the same transaction as the
write that made it happen. No second datastore, no dual-write window.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/rest-endpoints&quot;&gt;REST + live queries&lt;/a&gt;&lt;/strong&gt; — a document type becomes list / by-id / count /
create / replace / merge-patch / delete plus an SSE tail, in one line.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/mcp&quot;&gt;MCP server&lt;/a&gt;&lt;/strong&gt; — point Claude Code, Claude Desktop or Copilot at a store.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/migrating-v12-v13/&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;ConfigureDocument&amp;#x3C;T&gt;&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; — every per-type mapping method collapses
into one block. &lt;strong&gt;Breaking&lt;/strong&gt;, and the biggest thing to know before you upgrade.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/orleans-streams/&quot;&gt;Orleans persistent streams&lt;/a&gt;&lt;/strong&gt; (13.2) — durable, rewindable, inspectable streams
on the database the cluster already uses.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/vectordata&quot;&gt;VectorData connector&lt;/a&gt;&lt;/strong&gt; — MEVD over any vector-capable backend.&lt;/li&gt;
&lt;li&gt;Query terminals (&lt;code dir=&quot;auto&quot;&gt;First&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;Single&lt;/code&gt;), multi-property &lt;code dir=&quot;auto&quot;&gt;ExecuteUpdate&lt;/code&gt;, raw JSON terminals, validate-on-build,
real row locks, and multi-tenancy that survives a real deployment.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Full detail is on the &lt;a href=&quot;https://www.shinylib.net/documentdb/release-notes/&quot;&gt;release notes&lt;/a&gt;. This post walks the headliners.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;field-level-encryption&quot;&gt;Field-level encryption&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Some documents carry a field that shouldn’t be readable by anyone holding the database file — an SSN, a
government id, a bank account. Whole-database encryption (SQLCipher, TDE) protects the file; it does
nothing about the DBA, the backup, or the read replica.&lt;/p&gt;
&lt;p&gt;v13 encrypts individual properties:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;opts.&lt;/span&gt;&lt;span&gt;UseEncryptor&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AesGcmDocumentEncryptor&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;k1&quot;&lt;/span&gt;&lt;span&gt;, key));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;opts.&lt;/span&gt;&lt;span&gt;ConfigureDocument&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Patient&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;cfg&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cfg.&lt;/span&gt;&lt;span&gt;MapProperty&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.Ssn, &lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; p.&lt;/span&gt;&lt;span&gt;Encrypt&lt;/span&gt;&lt;span&gt;());                              &lt;/span&gt;&lt;span&gt;// opaque&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cfg.&lt;/span&gt;&lt;span&gt;MapProperty&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.Email, &lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; p.&lt;/span&gt;&lt;span&gt;Encrypt&lt;/span&gt;&lt;span&gt;(EncryptionMode.Deterministic)); &lt;/span&gt;&lt;span&gt;// still queryable&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;And that’s it. &lt;code dir=&quot;auto&quot;&gt;Get&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;ToList&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Insert&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Update&lt;/code&gt;, LINQ — nothing about how you read or write documents
changes. The stored body holds &lt;code dir=&quot;auto&quot;&gt;enc:1:k1:…&lt;/code&gt; where the value used to be.&lt;/p&gt;
&lt;p&gt;The mechanism is the part I’m happiest with: it’s installed as a &lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;JsonTypeInfo&lt;/code&gt; modifier&lt;/strong&gt;, so every
write path is covered &lt;em&gt;by construction&lt;/em&gt; — including temporal history and backup export, which are exactly
the two places a bolt-on encryption layer forgets. No provider knows it exists.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Deterministic mode&lt;/strong&gt; keeps equality filters working by rewriting the predicate’s constant into the
ciphertext actually stored, so &lt;code dir=&quot;auto&quot;&gt;Where(x =&gt; x.Email == &quot;a@b.com&quot;)&lt;/code&gt; still matches. The docs say in bold what
that costs you: deterministic ciphertext leaks equality and frequency. Anything that can’t be answered
against ciphertext — a range, a &lt;code dir=&quot;auto&quot;&gt;Contains&lt;/code&gt;, an &lt;code dir=&quot;auto&quot;&gt;OrderBy&lt;/code&gt; — throws with an explanation rather than quietly
matching nothing.&lt;/p&gt;
&lt;p&gt;Key rotation is a key ring plus &lt;code dir=&quot;auto&quot;&gt;RewrapAsync&amp;#x3C;T&gt;()&lt;/code&gt;, and values written before a property was mapped keep
reading, so you can turn it on for a store that already has data. AES-GCM is in the BCL, so there’s no new
dependency, and it’s AOT-clean.&lt;/p&gt;
&lt;aside aria-label=&quot;Breaking, and worth checking before you upgrade&quot;&gt;&lt;p aria-hidden=&quot;true&quot;&gt;Breaking, and worth checking before you upgrade&lt;/p&gt;&lt;div&gt;&lt;p&gt;Encrypted properties now leave the library as &lt;strong&gt;plaintext&lt;/strong&gt;, not as re-encrypted envelopes. The converters
are symmetric, so any code that materialized a document and serialized it again through the store’s own
&lt;code dir=&quot;auto&quot;&gt;JsonSerializerOptions&lt;/code&gt; was silently re-encrypting what it had just decrypted. Three places did: OData
responses, the AI &lt;code dir=&quot;auto&quot;&gt;query&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;get&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;insert&lt;/code&gt; tool results, and &lt;code dir=&quot;auto&quot;&gt;GetDiff&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;GetDiffBetween&lt;/code&gt;.&lt;/p&gt;&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;GetDiff&lt;/code&gt; was outright broken by this — under randomized mode it compared the stored envelope against a
freshly-encrypted one, so &lt;strong&gt;every&lt;/strong&gt; mapped property came back as changed, every time.&lt;/p&gt;&lt;p&gt;The fix is right, but it &lt;em&gt;is&lt;/em&gt; a disclosure change if you were relying on the old behaviour: a property that
went over the wire as an opaque &lt;code dir=&quot;auto&quot;&gt;enc:1:…&lt;/code&gt; string now goes over it as its value. Check your exposed OData
entity sets before upgrading.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;
&lt;div&gt;&lt;h2 id=&quot;a-transactional-outbox&quot;&gt;A transactional outbox&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The dual-write problem: you save an order and publish &lt;code dir=&quot;auto&quot;&gt;OrderPlaced&lt;/code&gt;. Two systems, no shared transaction.
The process dies in between and you have an order nobody was told about, or a message about an order that
doesn’t exist.&lt;/p&gt;
&lt;p&gt;An outbox fixes it by making the message part of the same transaction as the write:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;o.&lt;/span&gt;&lt;span&gt;AddOutbox&lt;/span&gt;&lt;span&gt;();                                  &lt;/span&gt;&lt;span&gt;// maps OutboxMessage → its own &quot;outbox&quot; table&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;services.&lt;/span&gt;&lt;span&gt;AddDocumentOutbox&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;BusDispatcher&lt;/span&gt;&lt;span&gt;&gt;();    &lt;/span&gt;&lt;span&gt;// the processor + IOutboxAdmin&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;using&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;session&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;OpenSession&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;session.&lt;/span&gt;&lt;span&gt;Add&lt;/span&gt;&lt;span&gt;(order).&lt;/span&gt;&lt;span&gt;Enqueue&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;OrderPlaced&lt;/span&gt;&lt;span&gt;(order.Id, order.Total));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; session.&lt;/span&gt;&lt;span&gt;SaveChanges&lt;/span&gt;&lt;span&gt;();                    &lt;/span&gt;&lt;span&gt;// both rows commit together, or neither does&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Or declaratively, per type: &lt;code dir=&quot;auto&quot;&gt;cfg.PublishToOutbox(ctx =&gt; new OrderChanged(...))&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Delivery is at-least-once with an attempt counter, exponential backoff and dead-lettering. Claiming is
per-message optimistic concurrency, so you scale workers by running more of them — there’s no coordinator.
The &lt;code dir=&quot;auto&quot;&gt;traceparent&lt;/code&gt; captured at enqueue is restored at dispatch, so a consumer’s span links back to the
request that caused the message.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;On the provider tier&lt;/strong&gt;, because this one has a real boundary: relational plus LiteDB. Everything else
implements a unit of work by &lt;em&gt;compensation&lt;/em&gt;, which does precisely nothing for a process that dies
mid-unit — which is the exact window an outbox exists to close. Rather than ship a promise that doesn’t
hold, a new &lt;code dir=&quot;auto&quot;&gt;IDocumentStore.SupportsTransactions&lt;/code&gt; capability gates those backends out &lt;strong&gt;at host startup&lt;/strong&gt;,
by name. Cosmos DB is a further no: “same transaction” there means “same logical partition”, and the store
partitions by type name. Those backends should use
&lt;a href=&quot;https://www.shinylib.net/documentdb/change-monitoring/&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;IChangeFeedDocumentStore&lt;/code&gt;&lt;/a&gt; instead.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;a-front-door-rest-sse-and-mcp&quot;&gt;A front door: REST, SSE and MCP&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;rest-endpoints-in-one-line&quot;&gt;REST endpoints, in one line&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;app.&lt;/span&gt;&lt;span&gt;MapDocuments&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;&quot;/orders&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.Operations &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; DocumentEndpoints.All;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.&lt;/span&gt;&lt;span&gt;AllowFilterOn&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.Status, &lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.Total);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.TypeInfo &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; AppJsonContext.Default.Order;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.&lt;/span&gt;&lt;span&gt;Scope&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;ITenantContext&lt;/span&gt;&lt;span&gt;&gt;((&lt;/span&gt;&lt;span&gt;tenant&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;_&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.TenantId &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; tenant.TenantId);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;})&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;RequireAuthorization&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orders&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;List, by-id, count, create, replace, RFC 7396 merge-patch, delete, and a live Server-Sent-Events tail.
Plain JSON, framework reference only, AOT-clean.&lt;/p&gt;
&lt;p&gt;Filtering uses the store’s own string grammar behind a &lt;strong&gt;per-endpoint field allowlist&lt;/strong&gt; — an unlisted field
is a &lt;code dir=&quot;auto&quot;&gt;400&lt;/code&gt;, not a table scan. &lt;code dir=&quot;auto&quot;&gt;take&lt;/code&gt; is clamped to &lt;code dir=&quot;auto&quot;&gt;MaxPageSize&lt;/code&gt;. Cursor paging, sparse fieldsets,
&lt;code dir=&quot;auto&quot;&gt;ETag&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;If-Match&lt;/code&gt; concurrency and &lt;code dir=&quot;auto&quot;&gt;ProblemDetails&lt;/code&gt; errors are all in.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Scope(...)&lt;/code&gt; is worth calling out: it’s resolved per request from the request’s DI scope, AND-ed into every
operation, and can’t be removed by the caller. Out-of-scope documents are &lt;code dir=&quot;auto&quot;&gt;404&lt;/code&gt;, not &lt;code dir=&quot;auto&quot;&gt;403&lt;/code&gt; — a &lt;code dir=&quot;auto&quot;&gt;403&lt;/code&gt;
confirms the row exists. &lt;code dir=&quot;auto&quot;&gt;MapDocumentCollection&lt;/code&gt; does the same for a schema-free
&lt;a href=&quot;https://www.shinylib.net/documentdb/json-collections&quot;&gt;JSON collection&lt;/a&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;an-mcp-server&quot;&gt;An MCP server&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;shiny-documentdb-mcp&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--provider&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sqlite&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--connection&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;Data Source=app.db&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddDocumentDbMcpServer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;mcp&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; { … }).&lt;/span&gt;&lt;span&gt;WithHttpTransport&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;app.&lt;/span&gt;&lt;span&gt;MapDocumentDbMcp&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;/mcp&quot;&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;RequireAuthorization&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;mcp&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Point an MCP client at a store and let it explore. The tools are the &lt;em&gt;same&lt;/em&gt; &lt;code dir=&quot;auto&quot;&gt;Extensions.AI&lt;/code&gt; tools — one
implementation, one security model — plus resources (&lt;code dir=&quot;auto&quot;&gt;documentdb://types&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;.../schema&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;.../sample&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;documentdb://stats&lt;/code&gt;), two prompts, and an audit line per call.&lt;/p&gt;
&lt;p&gt;Read-only by default, and writes need &lt;strong&gt;two&lt;/strong&gt; locks: the per-type capability &lt;em&gt;and&lt;/em&gt; &lt;code dir=&quot;auto&quot;&gt;AllowWrites()&lt;/code&gt;. Page
caps, property hiding, no raw-SQL tool, no schema mutation. The stdio tool discovers what to expose from
the stored &lt;code dir=&quot;auto&quot;&gt;TypeName&lt;/code&gt; discriminators, so it needs no compiled document classes at all, and it reads
connections from the admin tool’s existing profile store.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;ai-scopes-resolved-per-call&quot;&gt;AI scopes resolved per call&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The non-removable per-type &lt;code dir=&quot;auto&quot;&gt;Where&lt;/code&gt; scope on the AI tools now has a form resolved on every call from the
call’s own services — because “which rows may &lt;em&gt;this&lt;/em&gt; caller see” almost always lives in a request-scoped
service:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;t.&lt;/span&gt;&lt;span&gt;Where&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.TenantId &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;acme&quot;&lt;/span&gt;&lt;span&gt;)                                        &lt;/span&gt;&lt;span&gt;// static, as before&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Where&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;ITenantContext&lt;/span&gt;&lt;span&gt;&gt;((&lt;/span&gt;&lt;span&gt;tenant&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;_&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.TenantId &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; tenant.TenantId) &lt;/span&gt;&lt;span&gt;// resolved per call&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Where&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;IPermissionService&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;perms&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; …);                      &lt;/span&gt;&lt;span&gt;// async form&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;It fails &lt;strong&gt;closed&lt;/strong&gt;. A filter that throws, a service that won’t resolve, or a call with no services fails
the tool call rather than running the query unscoped, and registration asserts at startup that each
&lt;code dir=&quot;auto&quot;&gt;Where&amp;#x3C;TService&gt;&lt;/code&gt; service is actually registered.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;one-block-per-document-type&quot;&gt;One block per document type&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This is the breaking change to plan for. Every flat per-type mapping method is gone; the type is named
once and its whole configuration reads top to bottom:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;options.&lt;/span&gt;&lt;span&gt;ConfigureDocument&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Patient&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;cfg&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cfg.Table &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;Patients&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cfg.&lt;/span&gt;&lt;span&gt;MapIdProperty&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.Id);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cfg.&lt;/span&gt;&lt;span&gt;AddSoftDelete&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.IsDeleted);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cfg.&lt;/span&gt;&lt;span&gt;MapSpatialProperty&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; r.Location);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cfg.&lt;/span&gt;&lt;span&gt;MapProperty&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.Ssn, &lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; p.&lt;/span&gt;&lt;span&gt;Encrypt&lt;/span&gt;&lt;span&gt;(EncryptionMode.Deterministic));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cfg.&lt;/span&gt;&lt;span&gt;MapVectorProperty&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;d&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; d.Embedding, &lt;/span&gt;&lt;span&gt;dimensions&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;1536&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cfg.&lt;/span&gt;&lt;span&gt;MapTemporal&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.Retention &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; TimeSpan.&lt;/span&gt;&lt;span&gt;FromDays&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;90&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;MapTypeToTable&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;MapTypeToCollection&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;MapTypeToContainer&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;MapTypeToStore&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;MapTypeToPartition&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;MapIdProperty&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MapVersionProperty&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;AddQueryFilter&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;AddSoftDelete&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MapSpatialProperty&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;MapVectorProperty&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MapFullTextProperty&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MapComputedProperty&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MapBlob&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MapBlobCollection&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;MapTemporal&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MapIndexedProperty&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MapEncryptedProperty&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MapJsonSchema&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;OnBeforeWrite&lt;/code&gt; and
&lt;code dir=&quot;auto&quot;&gt;OnAfterWrite&lt;/code&gt; all move onto the builder. Store-level configuration is untouched.&lt;/p&gt;
&lt;p&gt;The builder is written once against &lt;code dir=&quot;auto&quot;&gt;IDocumentStoreOptions&lt;/code&gt;, so &lt;strong&gt;every provider gets the same surface&lt;/strong&gt;,
and provider packages add their own vocabulary over it (&lt;code dir=&quot;auto&quot;&gt;cfg.ToContainer&lt;/code&gt; on Cosmos, &lt;code dir=&quot;auto&quot;&gt;cfg.ToCollection&lt;/code&gt; on
MongoDB/LiteDB/Firestore, &lt;code dir=&quot;auto&quot;&gt;cfg.ToStore&lt;/code&gt; on IndexedDB, &lt;code dir=&quot;auto&quot;&gt;cfg.ToPartition&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;cfg.MapIndexedProperty&lt;/code&gt; on
Azure Table/DynamoDB). There’s a full old→new table in
&lt;a href=&quot;https://www.shinylib.net/documentdb/migrating-v12-v13/&quot;&gt;Migrating v12 → v13&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Two things landed alongside it:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Validate-on-build.&lt;/strong&gt; One configuration sweep when the store is constructed, reporting &lt;strong&gt;every&lt;/strong&gt; problem
together through a &lt;code dir=&quot;auto&quot;&gt;DocumentConfigurationException&lt;/code&gt; instead of one per restart. It catches features the
backend doesn’t have (a vector mapping on LiteDB, &lt;code dir=&quot;auto&quot;&gt;cfg.Table&lt;/code&gt; on RavenDB) and randomized-encrypted
properties used where the database has to read &lt;em&gt;through&lt;/em&gt; them — a full-text index, a computed expression, a
spatial or vector payload, the concurrency version. &lt;code dir=&quot;auto&quot;&gt;DocumentConfigurationValidator.Collect(options)&lt;/code&gt;
returns the same list without throwing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A model hook on &lt;code dir=&quot;auto&quot;&gt;DocumentContext&lt;/code&gt;&lt;/strong&gt;, so a source-generated context can declare its model next to its
&lt;code dir=&quot;auto&quot;&gt;[Document]&lt;/code&gt; list rather than inside &lt;code dir=&quot;auto&quot;&gt;AddDocumentStore&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;typeof&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Patient&lt;/span&gt;&lt;span&gt;))]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;partial&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AppContext&lt;/span&gt;&lt;span&gt; : &lt;/span&gt;&lt;span&gt;DocumentContext&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;static&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;partial&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;void&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;OnConfiguring&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;DocumentModelBuilder&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;model&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; model.&lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Patient&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;cfg&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; cfg.&lt;/span&gt;&lt;span&gt;MapTemporal&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.Retention &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; TimeSpan.&lt;/span&gt;&lt;span&gt;FromDays&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;90&lt;/span&gt;&lt;span&gt;)));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;query-surface&quot;&gt;Query surface&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Single-row terminals.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;First&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;FirstOrDefault&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Single&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;SingleOrDefault&lt;/code&gt;, with predicate
(&lt;code dir=&quot;auto&quot;&gt;First(x =&gt; x.Age == 40)&lt;/code&gt;) and string-grammar (&lt;code dir=&quot;auto&quot;&gt;First(&quot;status == &apos;open&apos;&quot;)&lt;/code&gt;) overloads, on every provider.&lt;/p&gt;
&lt;p&gt;They are not sugar over &lt;code dir=&quot;auto&quot;&gt;ToList()&lt;/code&gt;: the row limit reaches the provider, so a relational store emits
&lt;code dir=&quot;auto&quot;&gt;LIMIT 1&lt;/code&gt; and MongoDB/Cosmos page server-side rather than materializing every match to use the first.
&lt;code dir=&quot;auto&quot;&gt;Single&lt;/code&gt; fetches two rows, so “more than one matched” costs no extra round trip.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;ExecuteUpdate&lt;/code&gt; sets several properties at once:&lt;/strong&gt;&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Where&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.Status &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;open&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span&gt; o.CreatedAt &lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt; cutoff)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ExecuteUpdate&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;b&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; b&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Set&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.Status, &lt;/span&gt;&lt;span&gt;&quot;expired&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Set&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.ClosedAt, DateTimeOffset.UtcNow));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;One statement, one predicate evaluation, atomic — where three separate calls were three statements with a
window between them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Raw JSON terminals&lt;/strong&gt; end a typed query with JSON instead of &lt;code dir=&quot;auto&quot;&gt;T&lt;/code&gt;, so a document that only has to reach an
HTTP response never becomes an object:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;ctx.Response.ContentType &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;application/json&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;().&lt;/span&gt;&lt;span&gt;Where&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.Status &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;open&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WriteJsonArrayTo&lt;/span&gt;&lt;span&gt;(ctx.Response.Body, ct);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The whole typed builder still applies — &lt;code dir=&quot;auto&quot;&gt;Where&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;OrderBy&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Paginate&lt;/code&gt;, query filters, soft delete,
tenancy. OData and the AI &lt;code dir=&quot;auto&quot;&gt;query&lt;/code&gt; tool now read through this lane internally, which took an OData page from
two passes per document to one. (There’s more on this in
&lt;a href=&quot;https://www.shinylib.net/blog/2026/08/documentdb-hidden-gems/&quot;&gt;Hidden Gems&lt;/a&gt;.)&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;multi-tenancy-that-survives-deployment&quot;&gt;Multi-tenancy that survives deployment&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;AddMultiTenantDocumentStore&lt;/code&gt; got the hardening it needed: a &lt;strong&gt;bounded&lt;/strong&gt; store cache (LRU + idle eviction,
with lease-based deferred disposal so eviction can’t pull a store out from under a running request), an
overload taking a &lt;strong&gt;built store&lt;/strong&gt; so tenants can live on any provider, per-tenant initialization, and
&lt;code dir=&quot;auto&quot;&gt;IDocumentSession&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;IDocumentSessionFactory&lt;/code&gt; wired to the current tenant — they weren’t registered at all
before.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;services.&lt;/span&gt;&lt;span&gt;AddMultiTenantDocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;tenantId&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;MongoDbDocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;MongoOptionsFor&lt;/span&gt;&lt;span&gt;(tenantId)),   &lt;/span&gt;&lt;span&gt;// any provider&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.MaxCachedStores &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;250&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.IdleTimeout &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; TimeSpan.&lt;/span&gt;&lt;span&gt;FromMinutes&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;30&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.&lt;/span&gt;&lt;span&gt;SeedFromRegisteredSeeders&lt;/span&gt;&lt;span&gt;();      &lt;/span&gt;&lt;span&gt;// startup seeders now run per tenant, on first touch&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;There was also a genuinely nasty bug in here worth naming: the scoped &lt;code dir=&quot;auto&quot;&gt;IDocumentStore&lt;/code&gt; registration handed
the cached store to the DI scope, and the container disposes any &lt;code dir=&quot;auto&quot;&gt;IDisposable&lt;/code&gt; a scoped factory returns —
so a tenant’s shared store was &lt;strong&gt;disposed at the end of the first request that touched it&lt;/strong&gt;. The cache now
owns store lifetime and the scope owns a lease.&lt;/p&gt;
&lt;p&gt;The consequence is a breaking one: with eviction in play, a captured &lt;code dir=&quot;auto&quot;&gt;IDocumentStore&lt;/code&gt; from a tenant-routed
registration may become disposed while the process lives. Resolve it per scope, or opt out with
&lt;code dir=&quot;auto&quot;&gt;IdleTimeout = null&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;MaxCachedStores = int.MaxValue&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;orleans-durable-streams-132&quot;&gt;Orleans: durable streams (13.2)&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;siloBuilder.&lt;/span&gt;&lt;span&gt;AddDocumentDbStreams&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Default&quot;&lt;/span&gt;&lt;span&gt;, …);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Orleans clusters already use DocumentDb for membership, grain storage and reminders. Now they can use it
for &lt;strong&gt;streams&lt;/strong&gt; — no queue service to run, and a backlog you can actually look at when a queue won’t drain.&lt;/p&gt;
&lt;p&gt;Two design points I’d defend in a code review:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sequencing doesn’t use an identity column.&lt;/strong&gt; Identity hands out values at insert time, but rows appear at
commit time — so a late-committing transaction can be stepped over by the receiver’s watermark and its
event never delivered. Instead each queue has a counter row whose position is reserved under a row lock
inside the enqueue transaction, which makes assignment order and commit order the same order, and the
sequence gap-free.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;IsRewindable&lt;/code&gt; is true.&lt;/strong&gt; A subscriber can resume from a &lt;code dir=&quot;auto&quot;&gt;StreamSequenceToken&lt;/code&gt; older than anything still
in memory, because the cache replays the events table instead of reporting a cache miss. No queue-backed
provider can do that — behind Azure Queue or SQS the message is gone once handed over.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;IStreamAdmin&lt;/code&gt; reports per-queue depth, lag, retained history and which streams aren’t draining, and the
admin tool gained a &lt;strong&gt;Streams&lt;/strong&gt; screen. Backends: PostgreSQL, SQL Server, MySQL, MariaDB, Oracle,
CockroachDB — gated at silo start by the new &lt;code dir=&quot;auto&quot;&gt;SupportsPessimisticLocking&lt;/code&gt; capability, not a hard-coded
list. Expect thousands of events/sec on PostgreSQL; this is a database-backed queue, not Kafka.&lt;/p&gt;
&lt;p&gt;Which brings up the other 13.2 fix: &lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;LockMode&lt;/code&gt; now takes a real row lock.&lt;/strong&gt; It shipped validated but
inert — the API demanded a transaction and then issued an ordinary read, so &lt;code dir=&quot;auto&quot;&gt;session.Get(id, LockMode.Update)&lt;/code&gt;
blocked nothing anywhere. It now emits the engine’s own syntax: &lt;code dir=&quot;auto&quot;&gt;FOR UPDATE&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;FOR SHARE&lt;/code&gt; on PostgreSQL,
MySQL and CockroachDB, &lt;code dir=&quot;auto&quot;&gt;LOCK IN SHARE MODE&lt;/code&gt; on MariaDB, &lt;code dir=&quot;auto&quot;&gt;WITH (UPDLOCK, HOLDLOCK)&lt;/code&gt; on SQL Server. Oracle
throws for &lt;code dir=&quot;auto&quot;&gt;LockMode.Share&lt;/code&gt; rather than degrading to an unlocked read.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-admin-tools&quot;&gt;The admin tools&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Two of these have never had a post, so here they are properly.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;it-reads-an-encrypted-store--without-a-key&quot;&gt;It reads an encrypted store — without a key&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The admin tools shipped before encryption did, so they knew nothing about it. Now they read the envelope,
describe it, and refuse to quietly destroy it, all with &lt;strong&gt;no key at all&lt;/strong&gt;.&lt;/p&gt;
&lt;img src=&quot;https://www.shinylib.net/_astro/admin-encryption-browse.SOFtewNB_29YHQh.webp&quot; alt=&quot;Browse grid with the protected columns switched on, each cell reading &amp;#x27;encrypted · key k2&amp;#x27; and one row still showing a plaintext tax id&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1794&quot; height=&quot;1050&quot;&gt;
&lt;p&gt;An &lt;code dir=&quot;auto&quot;&gt;enc:1:k1:…&lt;/code&gt; value renders as what it is rather than a wall of base64, with a &lt;em&gt;show ciphertext&lt;/em&gt; toggle —
because pasting a deterministic ciphertext into the filter console is the only predicate that can match
one. The Structure tab reports the path’s type as &lt;code dir=&quot;auto&quot;&gt;encrypted&lt;/code&gt;, not &lt;code dir=&quot;auto&quot;&gt;string&lt;/code&gt;.&lt;/p&gt;
&lt;img src=&quot;https://www.shinylib.net/_astro/admin-encryption.C09ISHiD_kU7WM.webp&quot; alt=&quot;Encryption card listing two protected paths with their observed mode, the key ids in the sample, and per-key counts across the whole type&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1794&quot; height=&quot;438&quot;&gt;
&lt;p&gt;The Encryption card answers the one question &lt;code dir=&quot;auto&quot;&gt;RewrapAsync&amp;#x3C;T&gt;()&lt;/code&gt; can’t: &lt;strong&gt;did it finish?&lt;/strong&gt; It counts how many
values sit under each key id, how many are still plaintext, and how many are under a key the sample never
saw. Retiring a key early makes documents unreadable and nothing else tells you. Mode is reported as
&lt;code dir=&quot;auto&quot;&gt;deterministic (observed)&lt;/code&gt; only when a repeated ciphertext proves it, and never as “randomized”, which is
unprovable.&lt;/p&gt;
&lt;img src=&quot;https://www.shinylib.net/_astro/admin-encryption-downgrade.B-EBxa8D_2pqqWK.webp&quot; alt=&quot;The same editor refusing to save: taxId would be written in clear text, with a Save in clear text button beside Cancel&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1380&quot; height=&quot;570&quot;&gt;
&lt;p&gt;And there’s a downgrade guard on every write: saving a body where a path that held an envelope would go
back as clear text &lt;strong&gt;throws&lt;/strong&gt;, unless the caller explicitly allows it. The failure this prevents isn’t an
exception — the library reads a non-envelope as pre-encryption plaintext — so it was previously a silent
loss of protection.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;theres-a-terminal-front-end&quot;&gt;There’s a terminal front end&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Shipped in 12.5 and never blogged: &lt;strong&gt;ShinyDocDbMyAdmin.Tui&lt;/strong&gt;, a &lt;code dir=&quot;auto&quot;&gt;dotnet tool&lt;/code&gt; that is the same tool as the
web UI. Same connection store, same screens, no browser, works over SSH.&lt;/p&gt;
&lt;img src=&quot;https://www.shinylib.net/_astro/admin-tui-browse.B_8tMHKx_Z2mG3Hn.webp&quot; alt=&quot;The browse grid for the Order type, with columns inferred from the JSON bodies and the explorer tree on the left&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2590&quot; height=&quot;1300&quot;&gt;
&lt;p&gt;The column inference, the explorer tree, the filters — all the same, because both front ends are the same
core with a different renderer.&lt;/p&gt;
&lt;img src=&quot;https://www.shinylib.net/_astro/admin-tui-query-console.ByPqa_GT_2lzkRu.webp&quot; alt=&quot;The filter grammar console, with the table, type, where, order-by and project fields&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2590&quot; height=&quot;847&quot;&gt;
&lt;img src=&quot;https://www.shinylib.net/_astro/admin-tui-history.BcQm1CwH_2nDlXO.webp&quot; alt=&quot;The temporal history for the Order type, one row per version with operation, interval, duration and actor&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2590&quot; height=&quot;1451&quot;&gt;
&lt;img src=&quot;https://www.shinylib.net/_astro/admin-tui-vectors.BId4lRQH_ky4YY.webp&quot; alt=&quot;Embedding statistics for the Product type: dimensions, norms and per-document components&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2590&quot; height=&quot;1451&quot;&gt;
&lt;p&gt;Vectors, geometry, full-text, blobs, import/export, and the new outbox and streams screens are all there
too. Full tour: &lt;a href=&quot;https://www.shinylib.net/documentdb/admin/terminal&quot;&gt;the terminal UI docs&lt;/a&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;and-a-docker-desktop-extension&quot;&gt;And a Docker Desktop extension&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;docker&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;extension&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;install&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;aritchie/shiny-docdb-myadmin-extension&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Adds a tab that starts the admin container, waits for it, opens it — and hands it &lt;strong&gt;every database
container already running on your machine, connected&lt;/strong&gt;. PostgreSQL (including PostGIS, pgvector and
TimescaleDB), MySQL, MariaDB, SQL Server, Oracle Free/XE and CockroachDB are discovered by image, with
credentials taken from each container’s own environment. Addressing goes over the Docker network, so a
database that never published a port still works.&lt;/p&gt;
&lt;p&gt;Marketplace submissions are paused while Docker reviews Marketplace security, so for now you’ll need
&lt;strong&gt;Settings → Extensions → “Allow only extensions distributed through the Docker Marketplace”&lt;/strong&gt; turned off
to install it. The image is also mirrored to Docker Hub as &lt;code dir=&quot;auto&quot;&gt;aritchie/shiny-docdb-myadmin&lt;/code&gt; alongside GHCR —
one push, same digests, so the two can’t disagree about what a version contains.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;vectordata-connector&quot;&gt;VectorData connector&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;New package: &lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Extensions.VectorData&lt;/code&gt;. Point the .NET AI ecosystem (MEAI, the Microsoft
Agent Framework, Semantic Kernel) at a document store through MEVD’s &lt;code dir=&quot;auto&quot;&gt;VectorStore&lt;/code&gt; /
&lt;code dir=&quot;auto&quot;&gt;VectorStoreCollection&amp;#x3C;TKey, TRecord&gt;&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddDocumentDbVectorStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.DatabaseProvider &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;SqliteDatabaseProvider&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Data Source=app.db&quot;&lt;/span&gt;&lt;span&gt;) { EnableVectorExtension &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt; };&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.&lt;/span&gt;&lt;span&gt;MapVectorRecord&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Note&lt;/span&gt;&lt;span&gt;&gt;();      &lt;/span&gt;&lt;span&gt;// reads [VectorStoreKey]/[VectorStoreVector]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;notes&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; sp.&lt;/span&gt;&lt;span&gt;GetRequiredService&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;VectorStore&lt;/span&gt;&lt;span&gt;&gt;().&lt;/span&gt;&lt;span&gt;GetCollection&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;Note&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;&quot;Note&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;foreach&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;hit&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; notes.&lt;/span&gt;&lt;span&gt;SearchAsync&lt;/span&gt;&lt;span&gt;(query, &lt;/span&gt;&lt;span&gt;top&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;5&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;VectorSearchOptions&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Note&lt;/span&gt;&lt;span&gt;&gt; { Filter &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; n.Tag &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;release&quot;&lt;/span&gt;&lt;span&gt; })) { }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Every other MEVD connector is single-store. This one runs the same record model over &lt;strong&gt;any&lt;/strong&gt; vector-capable
DocumentDb backend — SQLite for dev and mobile, PostgreSQL/pgvector or SQL Server for production, Cosmos /
Atlas / Redis — swapped by configuration. MEVD’s filter is already an &lt;code dir=&quot;auto&quot;&gt;Expression&amp;#x3C;Func&amp;#x3C;T, bool&gt;&gt;&lt;/code&gt;, so it
goes to &lt;code dir=&quot;auto&quot;&gt;NearestVectors&lt;/code&gt; untouched and still pushes into the ANN search.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;breaking-changes&quot;&gt;Breaking changes&lt;/h2&gt;&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;ConfigureDocument&amp;#x3C;T&gt;&lt;/code&gt;&lt;/strong&gt; replaces every flat per-type mapping method.
See &lt;a href=&quot;https://www.shinylib.net/documentdb/migrating-v12-v13/&quot;&gt;Migrating v12 → v13&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Encrypted properties serialize as plaintext&lt;/strong&gt; out of OData, the AI tools and &lt;code dir=&quot;auto&quot;&gt;GetDiff&lt;/code&gt;. Check exposed
entity sets.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A document type carries one spatial / vector / full-text mapping.&lt;/strong&gt; A second one used to silently
replace the first; it now throws, naming both.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tenant stores can be disposed while the process lives&lt;/strong&gt; (idle/LRU eviction). Resolve per scope.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;DocumentBulkContext.Assignment&lt;/code&gt; → &lt;code dir=&quot;auto&quot;&gt;Assignments&lt;/code&gt;&lt;/strong&gt; (an ordered list, since a set-based update can carry
several).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;IDatabaseProvider.BuildJsonSetExpression()&lt;/code&gt;&lt;/strong&gt; takes the source expression and parameter names, and
&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;IDocumentStoreOptions&lt;/code&gt;&lt;/strong&gt; gained &lt;code dir=&quot;auto&quot;&gt;SerializerOptions&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;EnsureSerializerOptions()&lt;/code&gt;. Custom providers and
options classes only; every in-box one is updated.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;There’s also a smaller, quieter one in 13.2.1 that matters if you host DocumentDb over a transport:
&lt;code dir=&quot;auto&quot;&gt;DocumentPredicate.Compile&amp;#x3C;T&gt;&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;DocumentFilter.Parse&amp;#x3C;T&gt;&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;DocumentStoreAccessor.GetMappings&lt;/code&gt; are now
&lt;strong&gt;public&lt;/strong&gt;, in &lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Hosting&lt;/code&gt;. Hosts used to need an &lt;code dir=&quot;auto&quot;&gt;InternalsVisibleTo&lt;/code&gt; entry and a release
here to go with it; now a host package can live in any repo. &lt;code dir=&quot;auto&quot;&gt;DocumentPredicate.Compile&lt;/code&gt;’s contract is that
producing the delegate &lt;strong&gt;never uses &lt;code dir=&quot;auto&quot;&gt;Reflection.Emit&lt;/code&gt;&lt;/strong&gt; — enforced by a Native AOT publish in CI, not a
comment — so a host can check a scope against an incoming document on POST/PUT without forfeiting the trim
guarantee of the app doing the hosting.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;The full changelog is on the &lt;a href=&quot;https://www.shinylib.net/documentdb/release-notes/&quot;&gt;release notes&lt;/a&gt; page, and if you want the tour of
the things that &lt;em&gt;aren’t&lt;/em&gt; new but never got explained, that’s
&lt;a href=&quot;https://www.shinylib.net/blog/2026/08/documentdb-hidden-gems/&quot;&gt;Hidden Gems&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>documentdb</category><category>release</category><category>security</category><category>AI</category><category>Orleans</category></item><item><title>Introducing Shiny.Net.HttpServer — HTTP/1.1, HTTP/2 &amp; HTTP/3 Anywhere .NET Runs</title><link>https://www.shinylib.net/blog/2026/08/shiny-httpserver/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/08/shiny-httpserver/</guid><description>Ten new packages: a dependency-light, AOT/trim-clean HTTP server that runs where ASP.NET Core cannot — including inside a .NET MAUI app. Routing, middleware, DI scopes, WebSockets, SSE, TLS, source-generated typed endpoints, an OpenAPI document built with zero reflection, XML/MessagePack/protobuf without reflection, gRPC and gRPC-Web, a WebDAV mount, mediator and DocumentDb integrations, tunnelling, and an MCP transport.</description><pubDate>Tue, 11 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;ASP.NET Core is heavyweight and does not run on .NET MAUI, or in a number of embedded server
scenarios. That is the gap this fills.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Shiny.Net.HttpServer&lt;/strong&gt; is a dependency-light, AOT- and trim-clean HTTP/1.1, HTTP/2 and HTTP/3
server that runs anywhere .NET runs — plus tunnelling, so a server embedded in a phone app is
reachable from the public internet. It is available today in beta.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;server&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;HttpServer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;HttpServerOptions&lt;/span&gt;&lt;span&gt; { Port &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;8080&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;server.&lt;/span&gt;&lt;span&gt;MapGet&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;/ping&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; ctx.Response.&lt;/span&gt;&lt;span&gt;WriteAsync&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;pong&quot;&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; server.&lt;/span&gt;&lt;span&gt;RunAsync&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Only &lt;code dir=&quot;auto&quot;&gt;Microsoft.Extensions.*&lt;/code&gt; abstractions are taken as dependencies. Everything else — JSON, crypto,
JWT, OpenAPI, HPACK, QPACK — is built on what is in the box. Every shipping project has the trim, AOT
and single-file analyzers enabled, so “AOT-clean” is enforced by the build rather than claimed in a
readme.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-packages&quot;&gt;The packages&lt;/h2&gt;&lt;/div&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Net.HttpServer&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Net.HttpServer?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Net.HttpServer&quot; alt=&quot;NuGet package Shiny.Net.HttpServer&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;The server: protocols, routing, middleware, DI scopes, static files, WebSockets, SSE, sessions, OpenAPI, CORS, rate limiting, IP filtering, tunnelling — plus the compile-time typed-endpoint generator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Net.HttpServer.Jwt&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Net.HttpServer.Jwt?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Net.HttpServer.Jwt&quot; alt=&quot;NuGet package Shiny.Net.HttpServer.Jwt&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;JWT authentication on in-box crypto — no &lt;code dir=&quot;auto&quot;&gt;Microsoft.IdentityModel&lt;/code&gt; dependency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Net.HttpServer.Ssh&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Net.HttpServer.Ssh?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Net.HttpServer.Ssh&quot; alt=&quot;NuGet package Shiny.Net.HttpServer.Ssh&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;SSH remote-forwarding tunnel provider, including zero-account quick tunnels&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Net.HttpServer.AzureRelay&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Net.HttpServer.AzureRelay?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Net.HttpServer.AzureRelay&quot; alt=&quot;NuGet package Shiny.Net.HttpServer.AzureRelay&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Azure Relay tunnel provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Net.HttpServer.Mcp&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Net.HttpServer.Mcp?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Net.HttpServer.Mcp&quot; alt=&quot;NuGet package Shiny.Net.HttpServer.Mcp&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Model Context Protocol (Streamable HTTP) transport — host an MCP server without ASP.NET Core&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Net.HttpServer.Grpc&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Net.HttpServer.Grpc?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Net.HttpServer.Grpc&quot; alt=&quot;NuGet package Shiny.Net.HttpServer.Grpc&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/httpserver/grpc/&quot;&gt;gRPC and gRPC-Web&lt;/a&gt; — unary, client-, server- and bidirectional streaming. Marshalling is supplied, so nothing reflects over your messages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Net.HttpServer.WebDav&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Net.HttpServer.WebDav?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Net.HttpServer.WebDav&quot; alt=&quot;NuGet package Shiny.Net.HttpServer.WebDav&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A directory as an &lt;a href=&quot;https://www.shinylib.net/httpserver/webdav/&quot;&gt;RFC 4918&lt;/a&gt; class 1 &amp;#x26; 2 WebDAV mount&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Net.HttpServer.Mediator&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Net.HttpServer.Mediator?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Net.HttpServer.Mediator&quot; alt=&quot;NuGet package Shiny.Net.HttpServer.Mediator&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/httpserver/mediator/&quot;&gt;Shiny.Mediator&lt;/a&gt; requests, commands and streams as endpoints, bound at compile time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Net.HttpServer.DocumentDb&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Net.HttpServer.DocumentDb?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Net.HttpServer.DocumentDb&quot; alt=&quot;NuGet package Shiny.Net.HttpServer.DocumentDb&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A &lt;a href=&quot;https://www.shinylib.net/httpserver/documentdb/&quot;&gt;Shiny.DocumentDb&lt;/a&gt; document type as a complete HTTP resource&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.Net.HttpServer&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The typed-endpoint generator ships inside that package under &lt;code dir=&quot;auto&quot;&gt;analyzers/&lt;/code&gt; — it runs inside the
compiler and never lands in your output.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;four-tiers-one-app&quot;&gt;Four tiers, one app&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Each tier is built on the one below, and they compose in the same server. Pick the altitude that
suits the endpoint rather than the framework’s opinion.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tier 0 — a single delegate.&lt;/strong&gt; No routing at all.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;server.&lt;/span&gt;&lt;span&gt;OnRequest&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; ctx.Response.&lt;/span&gt;&lt;span&gt;WriteAsync&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;hello&quot;&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Tier 1 — &lt;a href=&quot;https://www.shinylib.net/httpserver/routing/&quot;&gt;routing&lt;/a&gt;.&lt;/strong&gt; Templates with constraints, mutable at runtime.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;server.&lt;/span&gt;&lt;span&gt;MapGet&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;/api/users/{id:int}&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;/* … */&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Tier 2 — &lt;a href=&quot;https://www.shinylib.net/httpserver/middleware/&quot;&gt;middleware&lt;/a&gt;.&lt;/strong&gt; ASP.NET-shaped.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;server.&lt;/span&gt;&lt;span&gt;UseAuthentication&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;server.&lt;/span&gt;&lt;span&gt;UseEmbeddedFiles&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;typeof&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;App&lt;/span&gt;&lt;span&gt;).Assembly, &lt;/span&gt;&lt;span&gt;&quot;MyApp.wwwroot&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Tier 3 — &lt;a href=&quot;https://www.shinylib.net/httpserver/endpoints/&quot;&gt;source-generated typed endpoints&lt;/a&gt;.&lt;/strong&gt; Constructor injection,
typed parameter binding, and OpenAPI metadata, all emitted at compile time.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;Route&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;/api/users&quot;&lt;/span&gt;&lt;span&gt;)]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;UserEndpoints&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;IUserService&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;users&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;ILogger&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;UserEndpoints&lt;/span&gt;&lt;span&gt;&gt; &lt;/span&gt;&lt;span&gt;logger&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;Get&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;/{id:int}&quot;&lt;/span&gt;&lt;span&gt;)]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;IActionResult&lt;/span&gt;&lt;span&gt;&gt; &lt;/span&gt;&lt;span&gt;GetUser&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;CancellationToken&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ct&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; users.&lt;/span&gt;&lt;span&gt;FindAsync&lt;/span&gt;&lt;span&gt;(id, ct) &lt;/span&gt;&lt;span&gt;is&lt;/span&gt;&lt;span&gt; { } &lt;/span&gt;&lt;span&gt;u&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;?&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;OkObjectResult&lt;/span&gt;&lt;span&gt;(u) &lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NotFoundResult&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;app.&lt;/span&gt;&lt;span&gt;MapMyAppEndpoints&lt;/span&gt;&lt;span&gt;();   &lt;/span&gt;&lt;span&gt;// emitted for every [Route] class in the assembly&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Results come in both spellings — &lt;code dir=&quot;auto&quot;&gt;Results.*&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;IActionResult&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-is-in-the-box&quot;&gt;What is in the box&lt;/h2&gt;&lt;/div&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/httpserver/protocols/&quot;&gt;Protocols&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HTTP/1.1, HTTP/2 (own HPACK), HTTP/3 (own QPACK), &lt;a href=&quot;https://www.shinylib.net/httpserver/websockets/&quot;&gt;WebSockets&lt;/a&gt;, &lt;a href=&quot;https://www.shinylib.net/httpserver/sse/&quot;&gt;SSE&lt;/a&gt;. Never guessed — ALPN over TLS, connection preface over cleartext&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/httpserver/static-files/&quot;&gt;Content&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Static files from disk or embedded resources, a published &lt;a href=&quot;https://www.shinylib.net/httpserver/blazor/&quot;&gt;Blazor WASM&lt;/a&gt; app, streaming multipart uploads, byte ranges and conditional GETs, a &lt;a href=&quot;https://www.shinylib.net/httpserver/file-browser/&quot;&gt;file browser&lt;/a&gt;, and brotli/gzip/deflate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/httpserver/authentication/&quot;&gt;Security&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Authentication and &lt;a href=&quot;https://www.shinylib.net/httpserver/authorization/&quot;&gt;authorization&lt;/a&gt; split ASP.NET-style — Basic, API key, cookie and &lt;a href=&quot;https://www.shinylib.net/httpserver/jwt/&quot;&gt;JWT&lt;/a&gt; schemes, policies, roles and claims, plus &lt;a href=&quot;https://www.shinylib.net/httpserver/cors/&quot;&gt;CORS&lt;/a&gt;, &lt;a href=&quot;https://www.shinylib.net/httpserver/rate-limiting/&quot;&gt;rate limiting&lt;/a&gt; and &lt;a href=&quot;https://www.shinylib.net/httpserver/ip-filtering/&quot;&gt;IP filtering&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/httpserver/tls/&quot;&gt;TLS&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Per-endpoint TLS, self-signed certificates generated in managed code (iOS and Android included), client certificates, and SPKI pinning for your own &lt;code dir=&quot;auto&quot;&gt;HttpClient&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/httpserver/openapi/&quot;&gt;OpenAPI&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;An OpenAPI 3.0.3 document built entirely from compile-time metadata and your &lt;code dir=&quot;auto&quot;&gt;JsonSerializerContext&lt;/code&gt; — no reflection, no document object model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/httpserver/serialization/&quot;&gt;Formats&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Content negotiation both ways — responses from &lt;code dir=&quot;auto&quot;&gt;Accept&lt;/code&gt;, request bodies from &lt;code dir=&quot;auto&quot;&gt;Content-Type&lt;/code&gt;. JSON built in; XML, MessagePack and protobuf one line each&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/httpserver/tunneling/&quot;&gt;Tunnelling&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A pluggable &lt;code dir=&quot;auto&quot;&gt;ITunnelProvider&lt;/code&gt;, the reference relay, &lt;a href=&quot;https://www.shinylib.net/httpserver/ssh/&quot;&gt;SSH remote forwarding&lt;/a&gt;, quick tunnels, and &lt;a href=&quot;https://www.shinylib.net/httpserver/azure-relay/&quot;&gt;Azure Relay&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/httpserver/hosting/&quot;&gt;Lifecycle&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Start, stop and restart at runtime, serialized and idempotent, with observable state&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;a href=&quot;https://www.shinylib.net/httpserver/routing/&quot;&gt;Route constraints&lt;/a&gt; cover &lt;code dir=&quot;auto&quot;&gt;byte&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;short&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;float&lt;/code&gt;, the temporal types and value
bounds (&lt;code dir=&quot;auto&quot;&gt;{page:range(1,100)}&lt;/code&gt;), and a constraint decides whether a route &lt;em&gt;matches&lt;/em&gt; rather than
converting anything — so a refused segment is a 404 and one that matched but will not parse is a 400.
Responses can also send trailing headers on all three protocol versions.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;reaching-a-device-from-the-internet&quot;&gt;Reaching a device from the internet&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A cellular device sits behind carrier-grade NAT with no routable address and no port to forward, and
an agent-based tunnel like ngrok or cloudflared cannot run on iOS at all — there is no process to
spawn. So tunnelling is built in, and every provider works the same way: the device opens an
&lt;strong&gt;outbound&lt;/strong&gt; connection and asks the far end to forward traffic back down it.&lt;/p&gt;
&lt;p&gt;The zero-ceremony version needs no account and nothing installed:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddHttpServer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;autoStart&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;false&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;configureServer&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;s&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; s.&lt;/span&gt;&lt;span&gt;MapGet&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;/&quot;&lt;/span&gt;&lt;span&gt;, …));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddQuickTunnel&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// then, from a button:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;url&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; tunnel.&lt;/span&gt;&lt;span&gt;StartAsync&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;QuickTunnel&lt;/code&gt; is &lt;code dir=&quot;auto&quot;&gt;INotifyPropertyChanged&lt;/code&gt; with &lt;code dir=&quot;auto&quot;&gt;PublicUrl&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;State&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;LastError&lt;/code&gt;, so a view binds
directly to it. &lt;strong&gt;Bind to &lt;code dir=&quot;auto&quot;&gt;PublicUrl&lt;/code&gt; rather than reading it once&lt;/strong&gt; — a free tunnel assigns a new
address on every reconnect, and a phone reconnects whenever it changes network. See
&lt;a href=&quot;https://www.shinylib.net/httpserver/ssh/&quot;&gt;SSH &amp;#x26; Quick Tunnels&lt;/a&gt; for the host presets and what each one requires.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;mcp-without-aspnet-core&quot;&gt;MCP without ASP.NET Core&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The MCP SDK’s own HTTP transport is an ASP.NET Core package, which makes this the piece that
genuinely could not be done any other way — an MCP server hosted inside a .NET MAUI app.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddMcpServer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.ServerInfo &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt;() { Name &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;thermostat&quot;&lt;/span&gt;&lt;span&gt;, Version &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;1.0.0&quot;&lt;/span&gt;&lt;span&gt; })&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WithTools&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;ThermostatTools&lt;/span&gt;&lt;span&gt;&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WithHttpTransport&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;app&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; builder.&lt;/span&gt;&lt;span&gt;Build&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;app.&lt;/span&gt;&lt;span&gt;MapMcp&lt;/span&gt;&lt;span&gt;();              &lt;/span&gt;&lt;span&gt;// POST/GET/DELETE/OPTIONS on /mcp&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;One thing the compiler cannot check for you: a tool’s parameter and return types are published to the
client as a JSON schema, and building that schema by reflection does not survive trimming. Tools that
trade only in primitives need nothing extra — give the rest a source-generated context, and
&lt;code dir=&quot;auto&quot;&gt;MapMcp()&lt;/code&gt; will name any type you missed at startup.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;JsonSerializable&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;typeof&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;))]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;JsonSerializable&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;typeof&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;IReadOnlyList&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Reading&lt;/span&gt;&lt;span&gt;&gt;))]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;partial&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ToolJson&lt;/span&gt;&lt;span&gt; : &lt;/span&gt;&lt;span&gt;JsonSerializerContext&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WithTools&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;ThermostatTools&lt;/span&gt;&lt;span&gt;&gt;(ToolJson.Default.Options)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Full detail on &lt;a href=&quot;https://www.shinylib.net/httpserver/mcp/&quot;&gt;MCP&lt;/a&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;formats-beyond-json&quot;&gt;Formats beyond JSON&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;JSON is the default and covers most of an HTTP API. For the rest — an integration behind a corporate
gateway that speaks XML, a battery-powered client that would rather not spend 40% more radio time on
braces, a service already generating protobuf from a &lt;code dir=&quot;auto&quot;&gt;.proto&lt;/code&gt; — formats plug in on both sides.
&lt;code dir=&quot;auto&quot;&gt;IOutputFormatter&lt;/code&gt; writes responses; &lt;code dir=&quot;auto&quot;&gt;IInputFormatter&lt;/code&gt; reads request bodies.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddContentNegotiation&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.NegotiateByDefault &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;;   &lt;/span&gt;&lt;span&gt;// Results.Ok(value) honours Accept&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.&lt;/span&gt;&lt;span&gt;AddXml&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.&lt;/span&gt;&lt;span&gt;AddMessagePack&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That is the whole setup, and &lt;strong&gt;no endpoint changes&lt;/strong&gt;. Request bodies are dispatched on &lt;code dir=&quot;auto&quot;&gt;Content-Type&lt;/code&gt;,
so every existing &lt;code dir=&quot;auto&quot;&gt;[FromBody]&lt;/code&gt; parameter, mediator contract and hand-written handler accepts the
registered formats immediately; responses are chosen from &lt;code dir=&quot;auto&quot;&gt;Accept&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;XmlSerializer&lt;/code&gt; could not be used for the XML side at all — it builds its mapping by reflecting over
the type at runtime, which is precisely what a trimmed or AOT-published app has thrown away. The same
goes for MessagePack-CSharp’s default resolver. Instead both read the source-generated &lt;code dir=&quot;auto&quot;&gt;JsonTypeInfo&lt;/code&gt;
your types already have, so:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;No new dependency, and no &lt;code dir=&quot;auto&quot;&gt;[XmlRoot]&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;[DataMember]&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;[MessagePackObject]&lt;/code&gt; on your DTOs.&lt;/li&gt;
&lt;li&gt;A type gets an XML or MessagePack representation on exactly the same terms as JSON — its metadata
was registered.&lt;/li&gt;
&lt;li&gt;The representations cannot drift apart, because the property names and converters have one source.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Reading XML is &lt;strong&gt;type-directed&lt;/strong&gt; for the same reason it has to be. XML has no types:
&lt;code dir=&quot;auto&quot;&gt;&amp;#x3C;postalCode&gt;01234&amp;#x3C;/postalCode&gt;&lt;/code&gt; is text, and only the target member knows whether that is a string, a
number or an enum ordinal. Guessing from the text is how a postal code arrives as the number &lt;code dir=&quot;auto&quot;&gt;1234&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Protobuf needs a schema, so its codecs are supplied rather than discovered — field numbers live in the
&lt;code dir=&quot;auto&quot;&gt;.proto&lt;/code&gt;, and the only thing that has them is the code &lt;code dir=&quot;auto&quot;&gt;protoc&lt;/code&gt; already generated:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;o.&lt;/span&gt;&lt;span&gt;AddProtobuf&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; p.&lt;/span&gt;&lt;span&gt;Add&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Reading&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;m&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; m.&lt;/span&gt;&lt;span&gt;ToByteArray&lt;/span&gt;&lt;span&gt;(), Reading.Parser.ParseFrom));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The same &lt;code dir=&quot;auto&quot;&gt;BinaryCodecRegistry&lt;/code&gt; carries CBOR, Avro, or MessagePack-CSharp’s native codec under whichever
media type you register it as. One behaviour change to note: a body whose &lt;code dir=&quot;auto&quot;&gt;Content-Type&lt;/code&gt; nothing reads
now answers &lt;strong&gt;415&lt;/strong&gt; rather than 400 — the difference between sending a caller to fix a header and
sending them hunting for a syntax error that is not there. Full detail on
&lt;a href=&quot;https://www.shinylib.net/httpserver/serialization/&quot;&gt;Serialization &amp;#x26; Formats&lt;/a&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;grpc-and-grpc-web&quot;&gt;gRPC and gRPC-Web&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;a href=&quot;https://www.shinylib.net/httpserver/grpc/&quot;&gt;gRPC&lt;/a&gt; runs over the same HTTP/2 stack as everything else, callable from
&lt;code dir=&quot;auto&quot;&gt;Grpc.Net.Client&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;grpcurl&lt;/code&gt; or any gRPC client in any language — and therefore from a .NET MAUI app,
where ASP.NET Core cannot run at all.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;app.&lt;/span&gt;&lt;span&gt;MapGrpcService&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;greet.Greeter&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;svc&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;svc.&lt;/span&gt;&lt;span&gt;AddMarshaller&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;HelloRequest&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;m&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; m.&lt;/span&gt;&lt;span&gt;ToByteArray&lt;/span&gt;&lt;span&gt;(), HelloRequest.Parser.ParseFrom);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;svc.&lt;/span&gt;&lt;span&gt;MapUnary&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;HelloRequest&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;HelloReply&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;&quot;SayHello&quot;&lt;/span&gt;&lt;span&gt;, (&lt;/span&gt;&lt;span&gt;request&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; …);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;All four method shapes are supported, with streams as &lt;code dir=&quot;auto&quot;&gt;IAsyncEnumerable&amp;#x3C;T&gt;&lt;/code&gt; in both directions flushed
as each message is yielded, &lt;code dir=&quot;auto&quot;&gt;grpc-timeout&lt;/code&gt; arriving as the handler’s &lt;code dir=&quot;auto&quot;&gt;CancellationToken&lt;/code&gt;, per-message
compression negotiated from &lt;code dir=&quot;auto&quot;&gt;grpc-accept-encoding&lt;/code&gt;, and status in trailers. &lt;code dir=&quot;auto&quot;&gt;MaxReceiveMessageSize&lt;/code&gt; is
enforced on the decompressed size as well as the length prefix, since a few compressed kilobytes can
otherwise expand into gigabytes.&lt;/p&gt;
&lt;p&gt;gRPC-Web is enabled by default in both framings, which is how a browser calls in and how anything on
HTTP/1.1 does. It matters more here than on a server: native gRPC needs a tunnel that forwards raw TCP,
and most hosted providers terminate HTTP/1.1, so gRPC-Web is generally what reaches a device from the
internet.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;a-directory-as-a-mounted-drive&quot;&gt;A directory as a mounted drive&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The &lt;a href=&quot;https://www.shinylib.net/httpserver/file-browser/&quot;&gt;file browser&lt;/a&gt; is a JSON API you drive with &lt;code dir=&quot;auto&quot;&gt;curl&lt;/code&gt;.
&lt;a href=&quot;https://www.shinylib.net/httpserver/webdav/&quot;&gt;WebDAV&lt;/a&gt; is the protocol every desktop already has a client for.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;app.&lt;/span&gt;&lt;span&gt;MapWebDav&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;/dav&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.RootPath &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; FileSystem.AppDataDirectory;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.AllowWrite &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;})&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;RequireAuthorization&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Point Finder or Windows Explorer at that URL and the app’s storage appears as a drive, with no client
code at all. RFC 4918 compliance classes 1 and 2 — &lt;code dir=&quot;auto&quot;&gt;PROPFIND&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;PROPPATCH&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MKCOL&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;COPY&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MOVE&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;LOCK&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;UNLOCK&lt;/code&gt; and the &lt;code dir=&quot;auto&quot;&gt;If&lt;/code&gt; header.&lt;/p&gt;
&lt;p&gt;Class 2 is on by default and is not really optional: Finder and the Windows redirector both mount a
class 1 server read-only whatever &lt;code dir=&quot;auto&quot;&gt;AllowWrite&lt;/code&gt; says. Writes and deletes are opt-in, and &lt;code dir=&quot;auto&quot;&gt;PROPFIND&lt;/code&gt; with
&lt;code dir=&quot;auto&quot;&gt;Depth: infinity&lt;/code&gt; is refused by default rather than walking a device’s storage into one response — note
that a missing &lt;code dir=&quot;auto&quot;&gt;Depth&lt;/code&gt; header means infinity per the specification, so it gets the same refusal.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;mediator-and-documentdb-as-endpoints&quot;&gt;Mediator and DocumentDb as endpoints&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;If you already write &lt;a href=&quot;https://www.shinylib.net/httpserver/mediator/&quot;&gt;Shiny.Mediator&lt;/a&gt; handlers, they can be endpoints. It is the
&lt;code dir=&quot;auto&quot;&gt;Shiny.Mediator.AspNet&lt;/code&gt; shape without ASP.NET Core:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;MediatorHttpGroup&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;/api/gadgets&quot;&lt;/span&gt;&lt;span&gt;)]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;GadgetHandlers&lt;/span&gt;&lt;span&gt; : &lt;/span&gt;&lt;span&gt;IRequestHandler&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;GetGadget&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;Gadget&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;MediatorHttpGet&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;/{id:int}&quot;&lt;/span&gt;&lt;span&gt;)]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Gadget&lt;/span&gt;&lt;span&gt;&gt; &lt;/span&gt;&lt;span&gt;Handle&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;GetGadget&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;request&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;IMediatorContext&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;CancellationToken&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ct&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; …;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;app.&lt;/span&gt;&lt;span&gt;MapGeneratedMediatorEndpoints&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The binding is the part that had to change. The ASP.NET package uses &lt;code dir=&quot;auto&quot;&gt;[AsParameters]&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;[FromBody]&lt;/code&gt;,
which is reflection over a delegate’s parameters and annotated &lt;code dir=&quot;auto&quot;&gt;RequiresDynamicCode&lt;/code&gt;; here a source
generator writes it out member by member at compile time, so a contract that cannot be bound is a build
error rather than a 500. An &lt;code dir=&quot;auto&quot;&gt;ICommand&lt;/code&gt; answers with a status code and no body; an &lt;code dir=&quot;auto&quot;&gt;IStreamRequest&amp;#x3C;T&gt;&lt;/code&gt;
becomes a Server-Sent Events response.&lt;/p&gt;
&lt;p&gt;And if the data lives in &lt;a href=&quot;https://www.shinylib.net/httpserver/documentdb/&quot;&gt;Shiny.DocumentDb&lt;/a&gt;, a document type is a complete
resource in one line:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;app.&lt;/span&gt;&lt;span&gt;MapDocuments&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;&quot;/orders&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.TypeInfo &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; AppJson.Default.Order;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.&lt;/span&gt;&lt;span&gt;AllowFilterOn&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.Status, &lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.Total);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.&lt;/span&gt;&lt;span&gt;Scope&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;ITenantContext&lt;/span&gt;&lt;span&gt;&gt;((&lt;/span&gt;&lt;span&gt;tenant&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;_&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.TenantId &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; tenant.TenantId);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;})&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;RequireAuthorization&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orders&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;List, by-id, count, create, replace, RFC 7396 merge-patch, delete and a live SSE tail, with filtering,
cursor paging, sparse fieldsets and &lt;code dir=&quot;auto&quot;&gt;ETag&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;If-Match&lt;/code&gt; — plus a schema-free lane for JSON collections
with no CLR type. It is a port of &lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.AspNetCore&lt;/code&gt; onto this server running the same
engine, so filters, cursors, scopes and merge semantics are identical on both.&lt;/p&gt;
&lt;p&gt;Two differences are deliberate. &lt;code dir=&quot;auto&quot;&gt;MapDocuments&lt;/code&gt; returns a builder that fans &lt;code dir=&quot;auto&quot;&gt;RequireAuthorization&lt;/code&gt;, CORS,
rate limiting and IP filtering across every route it registered, because this server attaches metadata
per route rather than per group — so adding an operation later cannot quietly leave one unprotected.
And there is no reflection fallback when &lt;code dir=&quot;auto&quot;&gt;TypeInfo&lt;/code&gt; is unset, because a fallback that works on a
desktop and throws on a trimmed phone is worse than a clear error in both places. Server-side scopes
answer 404 rather than 403 for a document outside the scope. The package requires Shiny.DocumentDb
13.2.1 or newer.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;trade-offs-worth-knowing-up-front&quot;&gt;Trade-offs worth knowing up front&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Two packages are deliberately &lt;strong&gt;not&lt;/strong&gt; AOT- or trim-clean, and they are separate packages for exactly
that reason. &lt;code dir=&quot;auto&quot;&gt;Shiny.Net.HttpServer.Ssh&lt;/code&gt; carries SSH.NET, which brings BouncyCastle and its own
algorithm registries. &lt;code dir=&quot;auto&quot;&gt;Shiny.Net.HttpServer.AzureRelay&lt;/code&gt; pulls in Azure.Identity, MSAL and
IdentityModel. Reference either and you accept the weight; leave them out and the core server stays
clean AOT.&lt;/p&gt;
&lt;p&gt;The other seven are clean, the four newest included — which is much of why gRPC and protobuf ask for
marshalling rather than discovering it, and why XML and MessagePack are written against your existing
JSON metadata instead of a reflective serializer.&lt;/p&gt;
&lt;p&gt;This is a beta. The API surface is settled and the test suite is substantial — around 1,260 tests, run
against a live socket rather than an in-memory harness, since most of what a server can get wrong only
exists at that boundary. But it has not yet been run in anger by a large number of people —
&lt;a href=&quot;https://github.com/shinyorg/httpserver/issues&quot;&gt;issues and feedback&lt;/a&gt; are very welcome.&lt;/p&gt;
&lt;p&gt;Finally, the security note that a tunnel makes urgent: a quick tunnel hands a public HTTPS address to
anyone who learns it, pointed at a server whose defaults were chosen for loopback. Put
&lt;a href=&quot;https://www.shinylib.net/httpserver/authentication/&quot;&gt;authentication&lt;/a&gt; in front of it before you open one.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;getting-started&quot;&gt;Getting started&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Full documentation is at &lt;a href=&quot;https://www.shinylib.net/httpserver/&quot;&gt;shinylib.net/httpserver&lt;/a&gt;, and the source — including a .NET
MAUI sample that serves a page, exposes a file browser behind a password, hosts an MCP server and
publishes the lot through a tunnel — is at
&lt;a href=&quot;https://github.com/shinyorg/httpserver&quot;&gt;github.com/shinyorg/httpserver&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>httpserver</category><category>release</category><category>MAUI</category><category>AOT</category><category>MCP</category><category>gRPC</category><category>WebDAV</category></item><item><title>Shiny.DocumentDb v12 — Doing All The Things!</title><link>https://www.shinylib.net/blog/2026/07/documentdb-v12/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/07/documentdb-v12/</guid><description>v12 ships ShinyDocDbMyAdmin — a phpMyAdmin-style web UI for your document stores — plus schema-free JSON collections, interceptors that can replace a write, soft delete in the box, a fully trim/AOT-clean package set, and a provider consolidation that deleted ~4,300 lines and found four real bugs on its way out.</description><pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.DocumentDb&quot; alt=&quot;NuGet package Shiny.DocumentDb&quot;&gt;&lt;/a&gt;
&lt;p&gt;v12 landed as three releases in three days, and between them they cover an unusually wide spread — a
whole new &lt;strong&gt;web UI&lt;/strong&gt;, a new &lt;strong&gt;schema-free write/query lane&lt;/strong&gt;, two new &lt;strong&gt;extensibility primitives&lt;/strong&gt;, a
full &lt;strong&gt;trim/AOT&lt;/strong&gt; pass, and the largest internal refactor the library has had.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/admin/&quot;&gt;ShinyDocDbMyAdmin&lt;/a&gt;&lt;/strong&gt; — a phpMyAdmin-style web front end for any relational
DocumentDb store, shipped as a container image.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/aspire#admin-ui-as-a-resource&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;AddDocumentDbAdmin&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; — that UI modelled as an Aspire
resource, already connected to every store you reference.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/json-collections/&quot;&gt;Schema-free JSON collections&lt;/a&gt;&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;store.Collection(&quot;orders&quot;)&lt;/code&gt;. No CLR
type, no mapping, no migration.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/interceptors#replacing-a-write-ctxcancel&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;ctx.Cancel()&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; — interceptors can now
&lt;em&gt;replace&lt;/em&gt; a write instead of only observing it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/soft-delete/&quot;&gt;Soft delete&lt;/a&gt;&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;AddSoftDelete&amp;#x3C;Customer&gt;(x =&gt; x.IsDeleted)&lt;/code&gt;, built entirely
out of the two public primitives above.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A shared provider surface&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;DocumentQueryBase&amp;#x3C;T&gt;&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;IDocumentStoreOptions&lt;/code&gt;, and a shared write
pipeline. ~4,300 lines of duplicated provider code deleted, and four real bugs fell out of the
conformance suites that replaced them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/aot/&quot;&gt;Trim/AOT clean, and verified&lt;/a&gt;&lt;/strong&gt; — every shipping package builds warning-free and
declares &lt;code dir=&quot;auto&quot;&gt;IsAotCompatible&lt;/code&gt;, checked by a real &lt;code dir=&quot;auto&quot;&gt;PublishAot&lt;/code&gt; run in CI.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The full changelog is on the &lt;a href=&quot;https://www.shinylib.net/documentdb/release-notes/&quot;&gt;release notes&lt;/a&gt; page. This post walks the
headliners, and closes with a summary of everything else DocumentDb does plus the package list.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;shinydocdbmyadmin&quot;&gt;ShinyDocDbMyAdmin&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The schema-free model has always had one rough edge: when something looks wrong in production, there is
no shape to inspect. &lt;code dir=&quot;auto&quot;&gt;SELECT Data FROM documents&lt;/code&gt; gives you a wall of minified JSON, and every SQL engine
has a different set of JSON functions to dig into it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ShinyDocDbMyAdmin&lt;/strong&gt; is the answer — a phpMyAdmin-style web UI for DocumentDb stores. No user management,
no server administration, no query planner. Just the documents.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;docker&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-p&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;8085:8080&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-v&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;shiny-docdb-myadmin:/data&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ghcr.io/shinyorg/shiny-docdb-myadmin&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;It’s a container image and nothing else — there’s deliberately no &lt;code dir=&quot;auto&quot;&gt;dotnet tool&lt;/code&gt; form, because packaging
the native provider binaries for every RID it might run on came out at ~120 MB to deliver what one image
delivers for the platform you’re actually on.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;browse&quot;&gt;Browse&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The grid’s columns are &lt;strong&gt;inferred by sampling documents&lt;/strong&gt;, so nested paths like &lt;code dir=&quot;auto&quot;&gt;customer.tier&lt;/code&gt; become
first-class columns instead of hiding inside a JSON blob. Filter on any envelope column or JSON path,
or quick-search the string fields.&lt;/p&gt;
&lt;img src=&quot;https://www.shinylib.net/_astro/admin-browse.MX9yBZTH_1z5Kty.webp&quot; alt=&quot;Browse grid for the Order type with columns id, number, status, customer.name, customer.email, customer.tier, total and Updated, inferred from nested JSON paths&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2250&quot; height=&quot;1350&quot;&gt;
&lt;p&gt;Numeric filters and sorts compare &lt;strong&gt;numerically&lt;/strong&gt;, which is not free on a schema-free store — the plain
JSON extract returns text on most engines, so an unhinted sort would put &lt;code dir=&quot;auto&quot;&gt;&quot;100&quot;&lt;/code&gt; before &lt;code dir=&quot;auto&quot;&gt;&quot;9&quot;&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Any row expands into a read-only, syntax-highlighted view of the whole body, with objects and arrays
collapsible — native &lt;code dir=&quot;auto&quot;&gt;&amp;#x3C;details&gt;&lt;/code&gt; elements, so it still works with scripting off.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;structure&quot;&gt;Structure&lt;/h3&gt;&lt;/div&gt;
&lt;img src=&quot;https://www.shinylib.net/_astro/admin-structure.D36ufDaS_Z1FHHln.webp&quot; alt=&quot;Structure tab for Order showing 42 documents, 13 fields seen, 15.3 KB of JSON, and a table of inferred paths with types, presence percentages, examples and per-path Index buttons&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2250&quot; height=&quot;1350&quot;&gt;
&lt;p&gt;The inferred shape of a type: every path, its type, &lt;strong&gt;how often it is actually present&lt;/strong&gt;, an example
value, and one-click create/drop of a JSON property index — named exactly as DocumentDb names its own,
so an index you create here is the same index &lt;code dir=&quot;auto&quot;&gt;CreateIndexAsync&lt;/code&gt; would have made.&lt;/p&gt;
&lt;p&gt;Because the store is schema-free this describes what the sampled documents &lt;em&gt;contain&lt;/em&gt;, not a contract.
A field below 100% is simply absent from some documents — which is frequently the bug you came to find.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;history&quot;&gt;History&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;If the type is mapped with &lt;a href=&quot;https://www.shinylib.net/documentdb/temporal/&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;MapTemporal&amp;#x3C;T&gt;&lt;/code&gt;&lt;/a&gt;, a History tab appears. Pick a
document, pick any two versions, and get a field-by-field diff by dotted path — or the two bodies side
by side. Any prior version can be restored behind a two-click confirm.&lt;/p&gt;
&lt;img src=&quot;https://www.shinylib.net/_astro/admin-history-diff.Zsy2_L_L_1m8X4L.webp&quot; alt=&quot;History tab showing four versions of one Order with operation, valid-from/valid-to interval, how long each was held and the actor, plus a field-level diff from version 1 to 4 showing status changed from refunded to delivered and tracking changed from null to 1Z397273X&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2250&quot; height=&quot;1575&quot;&gt;
&lt;p&gt;Writes made &lt;em&gt;in the UI&lt;/em&gt; to a temporal-mapped type record a version too, stamped with the actor
&lt;code dir=&quot;auto&quot;&gt;shiny-docdb-myadmin&lt;/code&gt;. That isn’t optional politeness: the admin layer writes SQL directly — it sits
below &lt;code dir=&quot;auto&quot;&gt;IDocumentStore&lt;/code&gt; because it has no CLR type to bind to — so none of the library’s temporal tracking
runs on its own. Without that, an edit here would change the row while the history sidecar went on
insisting the old body was current, and a quietly wrong audit trail is worse than no History tab at all.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;geometry&quot;&gt;Geometry&lt;/h3&gt;&lt;/div&gt;
&lt;img src=&quot;https://www.shinylib.net/_astro/admin-geometry.Bi6M_4VE_rEYN3.webp&quot; alt=&quot;Geometry tab for DeliveryZone rendering four polygons on an SVG map with latitude and longitude gridlines, a scale bar, and extent and vertex statistics beneath&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2250&quot; height=&quot;1470&quot;&gt;
&lt;p&gt;Types that store GeoJSON get a map — server-rendered SVG, zoom-to-feature and fit-all, with vertices,
length, area, centroid and OGC validity per document.&lt;/p&gt;
&lt;p&gt;It reads from the &lt;strong&gt;document body&lt;/strong&gt;, not the &lt;code dir=&quot;auto&quot;&gt;{table}_spatial&lt;/code&gt; sidecar. The sidecar holds only bounding
boxes for index pruning and its shape differs per provider; the GeoJSON in &lt;code dir=&quot;auto&quot;&gt;Data&lt;/code&gt; is the actual value and
is identical everywhere. So the map works on &lt;strong&gt;every&lt;/strong&gt; provider — including the ones with no spatial
support at all.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;sql-console&quot;&gt;SQL console&lt;/h3&gt;&lt;/div&gt;
&lt;img src=&quot;https://www.shinylib.net/_astro/admin-sql.CiGH1L7B_ZbLAG0.webp&quot; alt=&quot;SQL console running a parameterised GROUP BY over json_extract with a JSON parameters box binding status and min, and a result grid of tier, orders and revenue&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;2250&quot; height=&quot;1350&quot;&gt;
&lt;p&gt;Whatever you want to type, in the target database’s own dialect, with &lt;code dir=&quot;auto&quot;&gt;@name&lt;/code&gt; parameters bound from a
JSON box so the types stay honest — &lt;code dir=&quot;auto&quot;&gt;450&lt;/code&gt; binds a number, &lt;code dir=&quot;auto&quot;&gt;&quot;450&quot;&lt;/code&gt; binds text. There is no statement
allow-list, so give the tool a database account with the privileges you actually want it to have.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;plus&quot;&gt;Plus&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Edit&lt;/strong&gt; (JSON editor with format + validation, insert/duplicate/bulk delete), &lt;strong&gt;Blobs&lt;/strong&gt; (payload listing
that never selects the blob column, with inline preview and streamed downloads), &lt;strong&gt;Import / Export&lt;/strong&gt;
(JSON, NDJSON, CSV, or round-trippable envelope JSON), and &lt;strong&gt;Create&lt;/strong&gt; for a documents table or a type,
so a database whose store has never run isn’t a dead end.&lt;/p&gt;
&lt;p&gt;Supported on every relational backend: SQLite, SQLCipher, DuckDB, PostgreSQL, SQL Server, MySQL, MariaDB,
Oracle 23ai+ and CockroachDB. The document stores are out of scope — the tool works against the shared
&lt;code dir=&quot;auto&quot;&gt;Id / TypeName / Data / CreatedAt / UpdatedAt&lt;/code&gt; envelope over ADO.NET.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;its-an-aspire-resource-too&quot;&gt;It’s an Aspire resource too&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;You shouldn’t have to remember a &lt;code dir=&quot;auto&quot;&gt;docker run&lt;/code&gt; line. &lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Aspire.Hosting&lt;/code&gt; models the tool as
a resource, so it comes up with the rest of your app and every store you reference is already connected:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; builder.&lt;/span&gt;&lt;span&gt;AddPostgresDocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orders&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.&lt;/span&gt;&lt;span&gt;AddDocumentDbAdmin&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;port&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;8085&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WithReference&lt;/span&gt;&lt;span&gt;(store)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WithDataVolume&lt;/span&gt;&lt;span&gt;()                                  &lt;/span&gt;&lt;span&gt;// keep saved connections across runs&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WithHostPath&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;/Users/me/databases&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;/databases&quot;&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;// reach a file-backed store&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WithSecretKey&lt;/span&gt;&lt;span&gt;(builder.&lt;/span&gt;&lt;span&gt;AddParameter&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;admin-key&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;secret&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WithReadOnly&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;       &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;WaitFor&lt;/span&gt;&lt;span&gt;(store);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;WithReference&lt;/code&gt; is the same call a consuming service makes — the tool reads the
&lt;code dir=&quot;auto&quot;&gt;ConnectionStrings:{name}&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;Shiny:DocumentDb:{name}:Provider&lt;/code&gt; pair the hosting integration already
emits, so a Redis or blob reference in the same AppHost is ignored rather than turning into a junk
connection. Referenced stores show up under a &lt;strong&gt;from host&lt;/strong&gt; badge and can’t be edited or deleted from the
UI; they’re declared in the AppHost, so that’s where they change.&lt;/p&gt;
&lt;p&gt;The image tag defaults to the hosting package’s own version, so an integration upgrade brings the
matching UI with it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;schema-free-json-collections&quot;&gt;Schema-free JSON collections&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Not every document has a CLR type. Generic HTTP intake, message-bus payloads, ETL landing zones,
multi-tenant “extra fields” — the shape is decided by whoever sent it.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;orders&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Collection&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;orders&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; orders.&lt;/span&gt;&lt;span&gt;Insert&lt;/span&gt;&lt;span&gt;(jsonObject);   &lt;/span&gt;&lt;span&gt;// returns the stored id&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;doc&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; orders.&lt;/span&gt;&lt;span&gt;Get&lt;/span&gt;&lt;span&gt;(id);              &lt;/span&gt;&lt;span&gt;// JsonObject?&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;rows&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; orders.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Where&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;customer.name == &apos;bob&apos; and total:number &gt; 100&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;OrderBy&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;total:number desc&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Paginate&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;50&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ToList&lt;/span&gt;&lt;span&gt;();                               &lt;/span&gt;&lt;span&gt;// IReadOnlyList&amp;#x3C;JsonObject&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;No CLR type, no registered mapping, no migration. The collection name becomes the row’s &lt;code dir=&quot;auto&quot;&gt;TypeName&lt;/code&gt;, so a
schema-free collection shares a table with your typed documents without either seeing the other, and
needs &lt;strong&gt;zero schema change&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Querying is the &lt;a href=&quot;https://www.shinylib.net/documentdb/querying/&quot;&gt;string grammar&lt;/a&gt; only — there’s no &lt;code dir=&quot;auto&quot;&gt;T&lt;/code&gt; to write a lambda against —
lowered to the same IR and the same SQL as &lt;code dir=&quot;auto&quot;&gt;Query&amp;#x3C;T&gt;().Where(&quot;…&quot;)&lt;/code&gt;. Since no metadata says what a field
&lt;em&gt;is&lt;/em&gt;, the type is &lt;strong&gt;inferred&lt;/strong&gt; as the expression is built: from an explicit &lt;code dir=&quot;auto&quot;&gt;path:type&lt;/code&gt; hint, else from the
other operand (&lt;code dir=&quot;auto&quot;&gt;total &gt; 100&lt;/code&gt; is numeric), else from the function (&lt;code dir=&quot;auto&quot;&gt;lower(name)&lt;/code&gt; is a string), else
&lt;code dir=&quot;auto&quot;&gt;string&lt;/code&gt;. You need a hint wherever nothing else pins the type — an &lt;code dir=&quot;auto&quot;&gt;OrderBy&lt;/code&gt;, a &lt;code dir=&quot;auto&quot;&gt;min&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;max&lt;/code&gt;, a numeric
&lt;code dir=&quot;auto&quot;&gt;Project&lt;/code&gt; — because on every provider whose plain JSON extract returns text, &lt;code dir=&quot;auto&quot;&gt;OrderBy(&quot;total&quot;)&lt;/code&gt; sorts
lexicographically.&lt;/p&gt;
&lt;p&gt;Ids default to the &lt;code dir=&quot;auto&quot;&gt;id&lt;/code&gt; property, read case-insensitively and written verbatim; an absent id generates a
sortable &lt;strong&gt;UUIDv7&lt;/strong&gt; string and stamps it into your object.&lt;/p&gt;
&lt;p&gt;The same object serves the &lt;strong&gt;type-keyed&lt;/strong&gt; lane — &lt;code dir=&quot;auto&quot;&gt;store.Collection(typeof(Order))&lt;/code&gt; — where field paths
resolve through the document type’s metadata (naming policies, &lt;code dir=&quot;auto&quot;&gt;[JsonPropertyName]&lt;/code&gt;, leaf types), the body
still rides the full write pipeline (tenancy, temporal, versioning/CAS, spatial + vector sidecars,
interceptors, change notifications), and the &lt;em&gt;full&lt;/em&gt; function set is available including &lt;code dir=&quot;auto&quot;&gt;hasflag&lt;/code&gt;, the geo
predicates and the Lucene functions. That lane gained a fluent query builder and &lt;code dir=&quot;auto&quot;&gt;Remove&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;BatchRemove&lt;/code&gt;/
&lt;code dir=&quot;auto&quot;&gt;Clear&lt;/code&gt;, which it never had.&lt;/p&gt;
&lt;aside aria-label=&quot;Breaking&quot;&gt;&lt;p aria-hidden=&quot;true&quot;&gt;Breaking&lt;/p&gt;&lt;div&gt;&lt;p&gt;The eight &lt;code dir=&quot;auto&quot;&gt;Type&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;JsonNode&lt;/code&gt; members on &lt;code dir=&quot;auto&quot;&gt;IDocumentStore&lt;/code&gt; are &lt;strong&gt;removed&lt;/strong&gt; in favour of this one surface —
&lt;code dir=&quot;auto&quot;&gt;store.Insert(type, node)&lt;/code&gt; becomes &lt;code dir=&quot;auto&quot;&gt;store.Collection(type).Insert(obj)&lt;/code&gt;, and so on. There are no
&lt;code dir=&quot;auto&quot;&gt;[Obsolete]&lt;/code&gt; shims. The full mapping table is in the &lt;a href=&quot;https://www.shinylib.net/documentdb/release-notes/&quot;&gt;release notes&lt;/a&gt;.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;
&lt;p&gt;Relational providers only; everything else throws &lt;code dir=&quot;auto&quot;&gt;NotSupportedException&lt;/code&gt;, and the tier is pinned by a
test per provider.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;interceptors-can-replace-a-write&quot;&gt;Interceptors can replace a write&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;BeforeWrite&lt;/code&gt; could previously do exactly two things: mutate the document, or throw. Throwing aborts the
write &lt;em&gt;and&lt;/em&gt; the surrounding unit, so an interceptor could never change the &lt;strong&gt;shape&lt;/strong&gt; of an operation —
turn a delete into an update, route a write somewhere else, swallow one entirely.&lt;/p&gt;
&lt;p&gt;Now it can:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ArchiveOnDelete&lt;/span&gt;&lt;span&gt; : &lt;/span&gt;&lt;span&gt;IDocumentInterceptor&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BeforeWrite&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;DocumentWriteContext&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ctx&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;CancellationToken&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ct&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (ctx.Operation &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; DocumentOperation.Remove &lt;/span&gt;&lt;span&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span&gt; ctx.Document &lt;/span&gt;&lt;span&gt;is&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Invoice&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;inv&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;inv.Archived &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; ctx.Store.&lt;/span&gt;&lt;span&gt;Update&lt;/span&gt;&lt;span&gt;(inv, ct);   &lt;/span&gt;&lt;span&gt;// transaction-bound — commits with the original unit&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;ctx.&lt;/span&gt;&lt;span&gt;Cancel&lt;/span&gt;&lt;span&gt;();                      &lt;/span&gt;&lt;span&gt;// &quot;I performed this write myself&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Cancel()&lt;/code&gt; means the store issues &lt;strong&gt;no&lt;/strong&gt; write for the operation: no &lt;code dir=&quot;auto&quot;&gt;AfterWrite&lt;/code&gt;, no change notification,
no temporal history entry, and later interceptors in the chain are skipped. The caller still gets a normal
result — &lt;code dir=&quot;auto&quot;&gt;Cancel(false)&lt;/code&gt; makes &lt;code dir=&quot;auto&quot;&gt;Remove&lt;/code&gt; return &lt;code dir=&quot;auto&quot;&gt;false&lt;/code&gt;, and the bulk form’s &lt;code dir=&quot;auto&quot;&gt;Cancel(n)&lt;/code&gt; makes
&lt;code dir=&quot;auto&quot;&gt;ExecuteDelete&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;ExecuteUpdate&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Clear&lt;/code&gt; return &lt;code dir=&quot;auto&quot;&gt;n&lt;/code&gt;. &lt;code dir=&quot;auto&quot;&gt;DocumentBulkContext&lt;/code&gt; also gained &lt;code dir=&quot;auto&quot;&gt;QueryAs&amp;#x3C;T&gt;()&lt;/code&gt;, the
originating query with the same predicate and filters, so a set-based write can be re-issued differently.&lt;/p&gt;
&lt;p&gt;Supported on every provider.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;soft-delete-in-the-box&quot;&gt;Soft delete in the box&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Which is the whole point of the previous section — soft delete is now shipped, and it’s built out of
nothing but public parts:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;services.&lt;/span&gt;&lt;span&gt;AddDocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddSoftDelete&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Customer&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.IsDeleted)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Remove&lt;/span&gt;&lt;span&gt;(customer);                              &lt;/span&gt;&lt;span&gt;// sets the flag&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Customer&lt;/span&gt;&lt;span&gt;&gt;().&lt;/span&gt;&lt;span&gt;ToList&lt;/span&gt;&lt;span&gt;();                    &lt;/span&gt;&lt;span&gt;// flagged documents are invisible&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Customer&lt;/span&gt;&lt;span&gt;&gt;().&lt;/span&gt;&lt;span&gt;IncludeDeleted&lt;/span&gt;&lt;span&gt;().&lt;/span&gt;&lt;span&gt;ToList&lt;/span&gt;&lt;span&gt;();   &lt;/span&gt;&lt;span&gt;// read past it&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Customer&lt;/span&gt;&lt;span&gt;&gt;().&lt;/span&gt;&lt;span&gt;OnlyDeleted&lt;/span&gt;&lt;span&gt;().&lt;/span&gt;&lt;span&gt;ToList&lt;/span&gt;&lt;span&gt;();      &lt;/span&gt;&lt;span&gt;// just the flagged ones&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Remove&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;query.ExecuteDelete()&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;Clear&amp;#x3C;T&gt;()&lt;/code&gt; all set the flag instead of deleting. The flag is a
&lt;code dir=&quot;auto&quot;&gt;bool&lt;/code&gt;, or a nullable &lt;code dir=&quot;auto&quot;&gt;DateTime&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;DateTimeOffset&lt;/code&gt; stamped from a DI-registered &lt;code dir=&quot;auto&quot;&gt;TimeProvider&lt;/code&gt;. It comes
with &lt;code dir=&quot;auto&quot;&gt;SoftDelete&amp;#x3C;T&gt;(id)&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Restore&amp;#x3C;T&gt;(predicate)&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;PurgeDeleted&amp;#x3C;T&gt;(predicate?)&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;HardDelete&amp;#x3C;T&gt;(id)&lt;/code&gt;,
and &lt;code dir=&quot;auto&quot;&gt;SuppressInterceptors()&lt;/code&gt; for a raw write.&lt;/p&gt;
&lt;p&gt;It is &lt;strong&gt;not&lt;/strong&gt; built into the stores. It’s a named query filter (&lt;code dir=&quot;auto&quot;&gt;soft-delete&lt;/code&gt;) plus a cancelling
interceptor, composed from the two public building blocks and shipped as an extension method — which
means you can write your own variant the same way. A pleasant side effect: because a set-based delete is
re-issued as an &lt;em&gt;update&lt;/em&gt; over the same query, the spatial/vector/blob sidecar rows stay with the document
instead of being orphaned.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;one-surface-seventeen-providers&quot;&gt;One surface, seventeen providers&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The least visible change is the biggest. The nine non-relational &lt;code dir=&quot;auto&quot;&gt;IDocumentQuery&amp;#x3C;T&gt;&lt;/code&gt; implementations were
70–88% identical — the same builder state, the same client-side terminals, the same interceptor plumbing,
retyped per provider. Every document provider’s &lt;code dir=&quot;auto&quot;&gt;Insert&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Update&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Upsert&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Remove&lt;/code&gt; opened with the same
preamble and closed with the same tail, nine times over. That’s why v11.4’s &lt;code dir=&quot;auto&quot;&gt;ctx.Cancel()&lt;/code&gt; needed 45
hand-edited guards.&lt;/p&gt;
&lt;p&gt;v12 collapses all of it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;DocumentQueryBase&amp;#x3C;T&gt;&lt;/code&gt;&lt;/strong&gt; — providers supply a &lt;code dir=&quot;auto&quot;&gt;Clone&lt;/code&gt;, an &lt;code dir=&quot;auto&quot;&gt;ExecuteAsync(QueryPlan&amp;#x3C;T&gt;)&lt;/code&gt;, and two
set-based write primitives. Push-down stays explicit: &lt;code dir=&quot;auto&quot;&gt;ExecuteAsync&lt;/code&gt; returns a &lt;code dir=&quot;auto&quot;&gt;QueryExecution&amp;#x3C;T&gt;&lt;/code&gt;
declaring how much of the plan the engine satisfied (&lt;code dir=&quot;auto&quot;&gt;Complete&lt;/code&gt; for MongoDB/Cosmos, &lt;code dir=&quot;auto&quot;&gt;Candidates&lt;/code&gt; for
the key-value stores, &lt;code dir=&quot;auto&quot;&gt;Partial&lt;/code&gt; in between) and the base applies only the remainder client-side.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;IDocumentStoreOptions&lt;/code&gt;&lt;/strong&gt; — a three-method interface (&lt;code dir=&quot;auto&quot;&gt;AddInterceptor&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;AddBulkInterceptor&lt;/code&gt;/
&lt;code dir=&quot;auto&quot;&gt;AddQueryFilter&lt;/code&gt;) implemented by every options class, so a cross-cutting feature is written once.
&lt;code dir=&quot;auto&quot;&gt;AddSoftDelete&amp;#x3C;T&gt;&lt;/code&gt; collapsed from ten files to one, and &lt;code dir=&quot;auto&quot;&gt;MapJsonSchema&lt;/code&gt; went from relational-only to
&lt;strong&gt;every&lt;/strong&gt; provider for free.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A shared single-document write pipeline&lt;/strong&gt; on &lt;code dir=&quot;auto&quot;&gt;DocumentProviderBase&lt;/code&gt;. Persistence, conflict handling
and id generation stay per provider — Redis &lt;code dir=&quot;auto&quot;&gt;SET NX&lt;/code&gt;, Cosmos ETag, DynamoDB conditional writes, Azure
Table 409 genuinely differ.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;~4,300 lines of duplicated provider code deleted&lt;/strong&gt;, and &lt;code dir=&quot;auto&quot;&gt;DocumentQueryBase&amp;#x3C;T&gt;&lt;/code&gt; is &lt;strong&gt;public&lt;/strong&gt;, so an
out-of-repo provider implements four members instead of ~450 lines.&lt;/p&gt;
&lt;p&gt;The safety net is a pair of cross-provider conformance suites that assert the &lt;code dir=&quot;auto&quot;&gt;IDocumentQuery&amp;#x3C;T&gt;&lt;/code&gt;
contract, soft delete, and interceptor cancellation identically on all 17 providers. They found four real
bugs on their first run:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Bools in a predicate were broken on MySQL, MariaDB and SQL Server.&lt;/strong&gt; A &lt;code dir=&quot;auto&quot;&gt;bool&lt;/code&gt; JSON value was extracted
as the &lt;em&gt;text&lt;/em&gt; &lt;code dir=&quot;auto&quot;&gt;&apos;true&apos;&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;&apos;false&apos;&lt;/code&gt; and compared against &lt;code dir=&quot;auto&quot;&gt;1&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;0&lt;/code&gt;, so &lt;code dir=&quot;auto&quot;&gt;Where(x =&gt; x.IsActive)&lt;/code&gt; — or any bool
query filter, including soft delete’s — failed with &lt;code dir=&quot;auto&quot;&gt;Truncated incorrect DOUBLE value: &apos;false&apos;&lt;/code&gt;. SQL
Server had its own flavour: &lt;code dir=&quot;auto&quot;&gt;BIT&lt;/code&gt; is a value, not a condition.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cosmos &lt;code dir=&quot;auto&quot;&gt;ExecuteUpdate&lt;/code&gt; 404’d&lt;/strong&gt;, because the narrowed &lt;code dir=&quot;auto&quot;&gt;SELECT c.id, c.data&lt;/code&gt; projection dropped
&lt;code dir=&quot;auto&quot;&gt;typeName&lt;/code&gt; — the partition key — before writing back.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cosmos &lt;code dir=&quot;auto&quot;&gt;SetProperty&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;RemoveProperty&lt;/code&gt; ignored global query filters&lt;/strong&gt;, so a soft-deleted document could
still be updated by id.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;SetProperty&lt;/code&gt; with a date/&lt;code dir=&quot;auto&quot;&gt;Guid&lt;/code&gt; wrote malformed JSON&lt;/strong&gt; on the providers that build the value as a JSON
literal — an unquoted invariant &lt;code dir=&quot;auto&quot;&gt;ToString()&lt;/code&gt; that &lt;code dir=&quot;auto&quot;&gt;json_set&lt;/code&gt; rejected outright.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Every one of those was a real defect that had shipped. That’s the argument for the refactor.&lt;/p&gt;
&lt;aside aria-label=&quot;Breaking&quot;&gt;&lt;p aria-hidden=&quot;true&quot;&gt;Breaking&lt;/p&gt;&lt;div&gt;&lt;p&gt;&lt;strong&gt;Query builders are immutable everywhere now.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;Where&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;OrderBy&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Paginate&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;IgnoreQueryFilters&lt;/code&gt; on the
relational store used to mutate in place and return the same instance; every document provider has always
returned a copy. Both return a copy now — so a builder call used as a &lt;em&gt;statement&lt;/em&gt; silently loses its
clause. The compiler can’t flag it, so sweep for it:&lt;/p&gt;&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;q.&lt;/span&gt;&lt;span&gt;Where&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;x.IsDeleted);        &lt;/span&gt;&lt;span&gt;// ← discarded&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;q &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; q.&lt;/span&gt;&lt;span&gt;Where&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;x.IsDeleted);    &lt;/span&gt;&lt;span&gt;// ← correct on every provider&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;p&gt;Also: composing a query after &lt;code dir=&quot;auto&quot;&gt;Select(...)&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Project(...)&lt;/code&gt; now throws &lt;code dir=&quot;auto&quot;&gt;NotSupportedException&lt;/code&gt; on the
relational providers too, where it used to throw &lt;code dir=&quot;auto&quot;&gt;InvalidOperationException&lt;/code&gt;.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;
&lt;div&gt;&lt;h2 id=&quot;trim-and-aot-verified&quot;&gt;Trim and AOT, verified&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;v12.1 was a dedicated pass. The whole &lt;code dir=&quot;auto&quot;&gt;src/&lt;/code&gt; tree now builds with &lt;strong&gt;zero&lt;/strong&gt; IL2026/IL2075/IL2090/IL3050
warnings, and packages carry &lt;code dir=&quot;auto&quot;&gt;IsAotCompatible&lt;/code&gt; — which stamps &lt;code dir=&quot;auto&quot;&gt;[AssemblyMetadata(&quot;IsTrimmable&quot;,&quot;True&quot;)]&lt;/code&gt;.
Previously &lt;em&gt;no&lt;/em&gt; package declared it, so a consumer’s trimmer kept these assemblies whole instead of
trimming into them. The handful whose dependencies rule AOT out declare &lt;code dir=&quot;auto&quot;&gt;IsAotCompatible=false&lt;/code&gt; honestly
rather than silently.&lt;/p&gt;
&lt;p&gt;The mapping APIs (&lt;code dir=&quot;auto&quot;&gt;MapVersionProperty&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MapSpatialProperty&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MapVectorProperty&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MapFullTextProperty&lt;/code&gt;,
&lt;code dir=&quot;auto&quot;&gt;MapComputedProperty&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MapBlob&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MapBlobCollection&lt;/code&gt;) now declare
&lt;code dir=&quot;auto&quot;&gt;[DynamicallyAccessedMembers(PublicProperties)]&lt;/code&gt; on &lt;code dir=&quot;auto&quot;&gt;T&lt;/code&gt;, so the properties they resolve by name are
actually preserved. Passing a concrete type needs no change; code that forwards its own unannotated
generic parameter into them will get IL2091 and should propagate the annotation.&lt;/p&gt;
&lt;p&gt;And it’s checked, not assumed: &lt;code dir=&quot;auto&quot;&gt;samples/Sample.Aot&lt;/code&gt; publishes with &lt;code dir=&quot;auto&quot;&gt;PublishAot=true&lt;/code&gt;, exercises every
mapping kind and query surface once, treats any trim/AOT warning as a build error, and CI publishes and
runs it. The Roslyn analyzers can’t see warnings coming out of dependencies or code reachable only through
a call graph — a real ILC run can.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;everything-else-documentdb-does&quot;&gt;Everything else DocumentDb does&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;If you’re new here, the pitch is one sentence: &lt;strong&gt;turn any database into a schema-free JSON document store
with LINQ querying, no &lt;code dir=&quot;auto&quot;&gt;CREATE TABLE&lt;/code&gt;, no migrations, and one API across 19 backends.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Providers&lt;/strong&gt; — SQLite, SQLCipher, LiteDB, IndexedDB (Blazor WASM), DuckDB, PostgreSQL, CockroachDB, SQL
Server, MySQL, MariaDB, Oracle, Cosmos DB, MongoDB, Amazon DocumentDB, Azure Table Storage, DynamoDB,
Redis, RavenDB, Google Firestore.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/crud/&quot;&gt;CRUD&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Insert/Update/Upsert/Remove over whole object graphs; RFC 7396 merge-patch vs replace as a flag; &lt;code dir=&quot;auto&quot;&gt;SetProperty&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;RemoveProperty&lt;/code&gt; for surgical field updates; &lt;code dir=&quot;auto&quot;&gt;GetDiff&lt;/code&gt; returning an RFC 6902 patch; batch writes as one set operation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/querying/&quot;&gt;Querying&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Fluent LINQ over nested properties, &lt;code dir=&quot;auto&quot;&gt;Any()&lt;/code&gt;, string methods, captured variables; &lt;code dir=&quot;auto&quot;&gt;IAsyncEnumerable&lt;/code&gt; streaming; ordering, pagination and keyset cursors; a string-expression grammar (&lt;code dir=&quot;auto&quot;&gt;Where(&quot;…&quot;)&lt;/code&gt;) with full parity; Lucene-syntax queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/projections/&quot;&gt;Projections&lt;/a&gt; &amp;#x26; &lt;a href=&quot;https://www.shinylib.net/documentdb/aggregates/&quot;&gt;aggregates&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;SQL-level &lt;code dir=&quot;auto&quot;&gt;.Select()&lt;/code&gt; into DTOs; &lt;code dir=&quot;auto&quot;&gt;Max&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Min&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Sum&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Average&lt;/code&gt; terminals; &lt;code dir=&quot;auto&quot;&gt;GroupBy&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Having&lt;/code&gt; pushed down to real SQL &lt;code dir=&quot;auto&quot;&gt;GROUP BY&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/indexes/&quot;&gt;Indexes&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Expression-based JSON indexes (up to 30× faster), composite multi-column indexes, &lt;a href=&quot;https://www.shinylib.net/documentdb/computed-columns/&quot;&gt;computed properties&lt;/a&gt; as alias or materialized generated columns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/spatial/&quot;&gt;Spatial&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Full OGC &lt;code dir=&quot;auto&quot;&gt;Geometry&lt;/code&gt; model, topological predicates (&lt;code dir=&quot;auto&quot;&gt;GeoIntersects&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;GeoWithin&lt;/code&gt;, …), real 2-D indexes on every SQL provider plus Cosmos &lt;code dir=&quot;auto&quot;&gt;ST_*&lt;/code&gt; and Mongo &lt;code dir=&quot;auto&quot;&gt;2dsphere&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/vector/&quot;&gt;Vector&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;MapVectorProperty&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;NearestVectors(query, k)&lt;/code&gt; on pgvector, SQL Server 2025 &lt;code dir=&quot;auto&quot;&gt;VECTOR&lt;/code&gt;, Oracle 23ai, Cosmos DiskANN, Mongo Atlas &lt;code dir=&quot;auto&quot;&gt;$vectorSearch&lt;/code&gt;, DuckDB &lt;code dir=&quot;auto&quot;&gt;vss&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;sqlite-vec&lt;/code&gt;; &lt;code dir=&quot;auto&quot;&gt;AutoEmbedOnInsert&lt;/code&gt; via &lt;code dir=&quot;auto&quot;&gt;IEmbeddingGenerator&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/full-text/&quot;&gt;Full-text&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;MapFullTextProperty&lt;/code&gt; + relevance-ranked &lt;code dir=&quot;auto&quot;&gt;FullTextSearch&amp;#x3C;T&gt;&lt;/code&gt; on FTS5, &lt;code dir=&quot;auto&quot;&gt;tsvector&lt;/code&gt;+GIN, MySQL &lt;code dir=&quot;auto&quot;&gt;FULLTEXT&lt;/code&gt;, Oracle Text, SQL Server FTS, DuckDB &lt;code dir=&quot;auto&quot;&gt;fts&lt;/code&gt;, Cosmos, Mongo &lt;code dir=&quot;auto&quot;&gt;$text&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/temporal/&quot;&gt;Temporal&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;MapTemporal&amp;#x3C;T&gt;&lt;/code&gt; append-only versioning with &lt;code dir=&quot;auto&quot;&gt;History&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;AsOf&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Restore&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;GetDiffBetween&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;ChangesByActor&lt;/code&gt;, on every provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/blobs/&quot;&gt;Blobs&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;DocumentBlob&lt;/code&gt; sidecar storage, loaded on demand, so reads and the change feed never drag the bytes along&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/interceptors/&quot;&gt;Interceptors&lt;/a&gt; &amp;#x26; &lt;a href=&quot;https://www.shinylib.net/documentdb/query-filters/&quot;&gt;filters&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Per-document and set-based write hooks running inside the transaction; EF-style global query filters with named &lt;code dir=&quot;auto&quot;&gt;IgnoreQueryFilters&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/change-monitoring/&quot;&gt;Change monitoring&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;IAsyncEnumerable&amp;#x3C;DocumentChange&amp;#x3C;T&gt;&gt;&lt;/code&gt;, per-query &lt;code dir=&quot;auto&quot;&gt;.NotifyOnChange()&lt;/code&gt;, plus native change feeds on PostgreSQL &lt;code dir=&quot;auto&quot;&gt;LISTEN&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;NOTIFY&lt;/code&gt;, SQL Server Change Tracking and Cosmos Change Feed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/context/&quot;&gt;Context&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;EF-Core-style typed &lt;code dir=&quot;auto&quot;&gt;DocumentContext&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;DocumentSet&amp;#x3C;T&gt;&lt;/code&gt;, source-generated from &lt;code dir=&quot;auto&quot;&gt;[Document]&lt;/code&gt; attributes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/backup/&quot;&gt;Backup&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Streaming export/import/restore with native bulk-copy fast paths (&lt;code dir=&quot;auto&quot;&gt;COPY&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;SqlBulkCopy&lt;/code&gt;, DuckDB appender); hot file backup on SQLite/SQLCipher/LiteDB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-tenancy&lt;/td&gt;
&lt;td&gt;Shared-table &lt;code dir=&quot;auto&quot;&gt;TenantId&lt;/code&gt; filtering or tenant-per-database, resolved via &lt;code dir=&quot;auto&quot;&gt;ITenantResolver&lt;/code&gt;; consumer code unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrency&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;MapVersionProperty&lt;/code&gt; optimistic concurrency, ETag/conditional CAS, &lt;code dir=&quot;auto&quot;&gt;OpenSession()&lt;/code&gt; unit of work with explicit transactions and lock modes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/orleans/&quot;&gt;Orleans&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Grain storage, reminders, cluster membership and grain directory — with grain state queryable &lt;em&gt;without activating grains&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/ai-tools/&quot;&gt;AI tools&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Microsoft.Extensions.AI&lt;/code&gt; tool functions per document type, capability-gated, with non-removable per-type filter scopes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/odata/&quot;&gt;OData&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;$filter&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;$orderby&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;$top&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;$skip&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;$count&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;$select&lt;/code&gt; onto the fluent query, with per-entity-set governance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/data-sync/&quot;&gt;Data sync&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Offline-first bidirectional sync to an HTTP backend via &lt;code dir=&quot;auto&quot;&gt;Shiny.Data.Sync&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/diagnostics/&quot;&gt;Diagnostics&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Always-on OpenTelemetry metrics and &lt;code dir=&quot;auto&quot;&gt;ActivitySource&lt;/code&gt; spans, zero-cost when nobody’s listening&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/validation/&quot;&gt;Validation&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;JSON Schema (draft 2020-12) enforced just before the write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/geo-reference/&quot;&gt;Geo reference data&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Embedded US/CA states, provinces and cities that seed straight into any store&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/aspire/&quot;&gt;Aspire&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Provider as a deployment decision, keyed stores with health checks + OTel, and the admin UI as a resource&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div&gt;&lt;h2 id=&quot;packages&quot;&gt;Packages&lt;/h2&gt;&lt;/div&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Sqlite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.Sqlite&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.Sqlite?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.Sqlite&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Sqlite.SqlCipher&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.Sqlite.SqlCipher&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.Sqlite.SqlCipher?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.Sqlite.SqlCipher&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Sqlite.VectorSupport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.Sqlite.VectorSupport&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.Sqlite.VectorSupport?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.Sqlite.VectorSupport&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.PostgreSql&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.PostgreSql&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.PostgreSql?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.PostgreSql&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.CockroachDb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.CockroachDb&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.CockroachDb?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.CockroachDb&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.SqlServer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.SqlServer&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.SqlServer?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.SqlServer&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.MySql&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.MySql&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.MySql?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.MySql&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.MariaDb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.MariaDb&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.MariaDb?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.MariaDb&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Oracle&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.Oracle&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.Oracle?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.Oracle&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.DuckDb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.DuckDb&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.DuckDb?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.DuckDb&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.LiteDb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.LiteDb&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.LiteDb?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.LiteDb&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.IndexedDb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.IndexedDb&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.IndexedDb?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.IndexedDb&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.MongoDb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.MongoDb&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.MongoDb?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.MongoDb&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.CosmosDb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.CosmosDb&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.CosmosDb?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.CosmosDb&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.DocumentDb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.DocumentDb&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.DocumentDb?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.DocumentDb&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.AzureTable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.AzureTable&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.AzureTable?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.AzureTable&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.DynamoDb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.DynamoDb&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.DynamoDb?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.DynamoDb&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Redis&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.Redis&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.Redis?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.Redis&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.RavenDb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.RavenDb&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.RavenDb?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.RavenDb&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Firestore&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.Firestore&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.Firestore?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.Firestore&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Geo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.Geo&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.Geo?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.Geo&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.JsonSchema&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.JsonSchema&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.JsonSchema?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.JsonSchema&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Extensions.AI&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.Extensions.AI&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.Extensions.AI?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.Extensions.AI&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.OData&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.OData&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.OData?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.OData&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.AspNetCore.OData&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.AspNetCore.OData&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.AspNetCore.OData?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.AspNetCore.OData&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.AppDataSync&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.AppDataSync&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.AppDataSync?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.AppDataSync&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Orleans&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.Orleans&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.Orleans?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.Orleans&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Orleans.MongoDb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.Orleans.MongoDb&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.Orleans.MongoDb?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.Orleans.MongoDb&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Orleans.CosmosDb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.Orleans.CosmosDb&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.Orleans.CosmosDb?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.Orleans.CosmosDb&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Aspire.Hosting&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.Aspire.Hosting&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.Aspire.Hosting?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.Aspire.Hosting&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Aspire.Client&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.Aspire.Client&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.Aspire.Client?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.Aspire.Client&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Aspire.Orleans&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb.Aspire.Orleans&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb.Aspire.Orleans?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.DocumentDb.Aspire.Orleans&quot;&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The admin UI isn’t a package — it’s &lt;code dir=&quot;auto&quot;&gt;ghcr.io/shinyorg/shiny-docdb-myadmin&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;links&quot;&gt;Links&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Docs&lt;/strong&gt; — &lt;a href=&quot;https://www.shinylib.net/documentdb/&quot;&gt;shinylib.net/documentdb&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Release notes&lt;/strong&gt; — &lt;a href=&quot;https://www.shinylib.net/documentdb/release-notes/&quot;&gt;full v12 changelog&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Admin UI&lt;/strong&gt; — &lt;a href=&quot;https://www.shinylib.net/documentdb/admin/&quot;&gt;ShinyDocDbMyAdmin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JSON collections&lt;/strong&gt; — &lt;a href=&quot;https://www.shinylib.net/documentdb/json-collections/&quot;&gt;/documentdb/json-collections&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Soft delete&lt;/strong&gt; — &lt;a href=&quot;https://www.shinylib.net/documentdb/soft-delete/&quot;&gt;/documentdb/soft-delete&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Interceptors&lt;/strong&gt; — &lt;a href=&quot;https://www.shinylib.net/documentdb/interceptors/&quot;&gt;/documentdb/interceptors&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AOT setup&lt;/strong&gt; — &lt;a href=&quot;https://www.shinylib.net/documentdb/aot/&quot;&gt;/documentdb/aot&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Provider comparison&lt;/strong&gt; — &lt;a href=&quot;https://www.shinylib.net/documentdb/providers/&quot;&gt;/documentdb/providers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitHub&lt;/strong&gt; — &lt;a href=&quot;https://github.com/shinyorg/DocumentDb&quot;&gt;github.com/shinyorg/DocumentDb&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There’s also a &lt;a href=&quot;https://github.com/shinyorg/DocumentDb/tree/main/skills/shiny-documentdb&quot;&gt;Claude Code skill&lt;/a&gt;
that keeps an agent honest about the API surface if you’re generating DocumentDb code.&lt;/p&gt;</content:encoded><category>documentdb</category><category>release</category><category>aspire</category><category>AOT</category></item><item><title>Give Your MAUI Agent Hands — Contacts, Reminders &amp; Location AI Tools</title><link>https://www.shinylib.net/blog/2026/07/shiny-ai-tools/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/07/shiny-ai-tools/</guid><description>Three new Microsoft.Extensions.AI packages turn device services into tools an LLM can call — Shiny.Contacts.Extensions.AI, Shiny.Notifications.Extensions.AI, and Shiny.Locations.Extensions.AI. Opt-in, read-only by default, AOT-safe, and they compose into a single IChatClient.</description><pubDate>Mon, 06 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When we shipped &lt;code dir=&quot;auto&quot;&gt;Shiny.Health.Extensions.AI&lt;/code&gt;, it proved a simple point: a chat model becomes far more useful when it can &lt;em&gt;do&lt;/em&gt; things instead of just talk about them. Give it a tool to read step counts and “how did I sleep last week?” stops being a guess and starts being an answer pulled from the device.&lt;/p&gt;
&lt;p&gt;So we did the same for three more device services. Three new packages expose Shiny modules as &lt;a href=&quot;https://learn.microsoft.com/dotnet/ai/&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;Microsoft.Extensions.AI&lt;/code&gt;&lt;/a&gt; tool functions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.Contacts.Extensions.AI&lt;/code&gt;&lt;/strong&gt; — search and manage the device address book&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.Notifications.Extensions.AI&lt;/code&gt;&lt;/strong&gt; — schedule and cancel reminders&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.Locations.Extensions.AI&lt;/code&gt;&lt;/strong&gt; — read-only GPS: where you are, and how far/long to somewhere else&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;They all follow the same shape as Health: you &lt;strong&gt;opt-in&lt;/strong&gt; to exactly which operations the model can see (a read/write allow-list you control on behalf of the agent — not an OS permission prompt), it’s &lt;strong&gt;read-only by default&lt;/strong&gt;, and the whole thing is &lt;code dir=&quot;auto&quot;&gt;IsAotCompatible&lt;/code&gt; — schemas are hand-built and results come back as &lt;code dir=&quot;auto&quot;&gt;JsonNode&lt;/code&gt;, so there’s no reflection in the tool path.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;contacts&quot;&gt;Contacts&lt;/h2&gt;&lt;/div&gt;
&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Contacts.Extensions.AI&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Contacts.Extensions.AI?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Contacts.Extensions.AI&quot; alt=&quot;NuGet package Shiny.Contacts.Extensions.AI&quot;&gt;&lt;/a&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.Contacts.Extensions.AI&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddContactStore&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddContactsAITools&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;tools&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; tools&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddContacts&lt;/span&gt;&lt;span&gt;(ContactAICapabilities.ReadWrite)   &lt;/span&gt;&lt;span&gt;// Read is the default; ReadWrite adds create/update/delete&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;This generates &lt;code dir=&quot;auto&quot;&gt;search_contacts&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;get_contact&lt;/code&gt;, and — when you opt-in to &lt;code dir=&quot;auto&quot;&gt;Write&lt;/code&gt; — &lt;code dir=&quot;auto&quot;&gt;create_contact&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;update_contact&lt;/code&gt;, and &lt;code dir=&quot;auto&quot;&gt;delete_contact&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What you’d use it for:&lt;/strong&gt; a personal-assistant or CRM-lite app where the user talks to the address book instead of tapping through it — “grab that number so I can text them,” “add the person on this business card,” “which of my contacts work at Acme?” The agent looks up an id with &lt;code dir=&quot;auto&quot;&gt;search_contacts&lt;/code&gt;, then acts on it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example questions:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“What’s Jane Doe’s mobile number?”&lt;/li&gt;
&lt;li&gt;“Add a contact for my new dentist — Dr. Patel at Bright Smiles Dental, 555-0142.”&lt;/li&gt;
&lt;li&gt;“Change my brother Mike’s email to &lt;a href=&quot;mailto:mike@newjob.com&quot;&gt;mike@newjob.com&lt;/a&gt;.”&lt;/li&gt;
&lt;li&gt;“Do I have anyone saved from Acme Corp?”&lt;/li&gt;
&lt;li&gt;“Delete the contact for my old landlord.” &lt;em&gt;(destructive — tell the model to confirm first)&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;reminders&quot;&gt;Reminders&lt;/h2&gt;&lt;/div&gt;
&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Notifications.Extensions.AI&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Notifications.Extensions.AI?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Notifications.Extensions.AI&quot; alt=&quot;NuGet package Shiny.Notifications.Extensions.AI&quot;&gt;&lt;/a&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.Notifications.Extensions.AI&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddNotifications&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddNotificationAITools&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;tools&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; tools&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddReminders&lt;/span&gt;&lt;span&gt;(ReminderAICapabilities.ReadWrite, &lt;/span&gt;&lt;span&gt;channel&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;reminders&quot;&lt;/span&gt;&lt;span&gt;)   &lt;/span&gt;&lt;span&gt;// channel is optional&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Local notifications, framed as &lt;strong&gt;reminders&lt;/strong&gt;. You get &lt;code dir=&quot;auto&quot;&gt;list_reminders&lt;/code&gt;, and — with &lt;code dir=&quot;auto&quot;&gt;Write&lt;/code&gt; — &lt;code dir=&quot;auto&quot;&gt;create_reminder&lt;/code&gt; (fire now, at a specific date/time, or daily at a set time) and &lt;code dir=&quot;auto&quot;&gt;cancel_reminder&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What you’d use it for:&lt;/strong&gt; any “remind me…” flow in a chat assistant — todo apps, habit and medication trackers, follow-up nudges. The user speaks a reminder in natural language and the model schedules a real local notification; &lt;code dir=&quot;auto&quot;&gt;scheduleFor&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;repeatDailyAt&lt;/code&gt; cover one-shot and recurring.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example questions:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“Remind me to call the plumber at 3pm today.”&lt;/li&gt;
&lt;li&gt;“Set a daily reminder to take my meds at 8:30am.”&lt;/li&gt;
&lt;li&gt;“What reminders do I have set?”&lt;/li&gt;
&lt;li&gt;“Cancel the one about the dentist.”&lt;/li&gt;
&lt;li&gt;“Nudge me to water the plants every evening at 6.”&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;location&quot;&gt;Location&lt;/h2&gt;&lt;/div&gt;
&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Locations.Extensions.AI&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Locations.Extensions.AI?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Locations.Extensions.AI&quot; alt=&quot;NuGet package Shiny.Locations.Extensions.AI&quot;&gt;&lt;/a&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.Locations.Extensions.AI&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddGps&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddLocationAITool&lt;/span&gt;&lt;span&gt;();   &lt;/span&gt;&lt;span&gt;// read-only — there&apos;s no write capability for GPS&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;GPS is &lt;strong&gt;read-only&lt;/strong&gt;, so there’s no builder to configure — a single &lt;code dir=&quot;auto&quot;&gt;AddLocationAITool()&lt;/code&gt; registers &lt;code dir=&quot;auto&quot;&gt;get_current_location&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;get_distance_to&lt;/code&gt;, and &lt;code dir=&quot;auto&quot;&gt;estimate_travel_time&lt;/code&gt;. The agent can learn where the user is and reason about distance and rough travel time to a destination.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What you’d use it for:&lt;/strong&gt; travel and field-service apps, delivery/ETA estimates, “am I near…” checks, or any assistant that should be location-aware. One honest caveat baked into the tools: distances are &lt;strong&gt;great-circle (straight-line)&lt;/strong&gt; and travel times use an assumed average speed — the results say so in a &lt;code dir=&quot;auto&quot;&gt;note&lt;/code&gt; field, so the model caveats its answer rather than pretending to be a routing engine.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example questions:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“Where am I right now?”&lt;/li&gt;
&lt;li&gt;“How far is it from here to 51.4700, -0.4543?”&lt;/li&gt;
&lt;li&gt;“Roughly how long to cycle to the park at 51.51, -0.12?”&lt;/li&gt;
&lt;li&gt;“Am I within 2 km of the office?”&lt;/li&gt;
&lt;li&gt;“What’s my current speed and heading?”&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;compose-them-into-one-agent&quot;&gt;Compose them into one agent&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Each package hands you a small bundle you resolve from DI. Concatenate their &lt;code dir=&quot;auto&quot;&gt;.Tools&lt;/code&gt; and pass the lot to any &lt;code dir=&quot;auto&quot;&gt;IChatClient&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;tools&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;List&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;AITool&lt;/span&gt;&lt;span&gt;&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;tools.&lt;/span&gt;&lt;span&gt;AddRange&lt;/span&gt;&lt;span&gt;(sp.&lt;/span&gt;&lt;span&gt;GetRequiredService&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;ContactAITools&lt;/span&gt;&lt;span&gt;&gt;().Tools);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;tools.&lt;/span&gt;&lt;span&gt;AddRange&lt;/span&gt;&lt;span&gt;(sp.&lt;/span&gt;&lt;span&gt;GetRequiredService&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;NotificationAITools&lt;/span&gt;&lt;span&gt;&gt;().Tools);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;tools.&lt;/span&gt;&lt;span&gt;AddRange&lt;/span&gt;&lt;span&gt;(sp.&lt;/span&gt;&lt;span&gt;GetRequiredService&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;LocationAITools&lt;/span&gt;&lt;span&gt;&gt;().Tools);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;response&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; chatClient.&lt;/span&gt;&lt;span&gt;GetResponseAsync&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;messages,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ChatOptions&lt;/span&gt;&lt;span&gt; { Tools &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; tools }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Now a single message can fan out across all three:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;“I’m meeting Sarah at the office at 5 — how far am I, and remind me to leave in an hour.”&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The model calls &lt;code dir=&quot;auto&quot;&gt;search_contacts&lt;/code&gt; for Sarah if it needs her details, &lt;code dir=&quot;auto&quot;&gt;get_distance_to&lt;/code&gt; for the office, and &lt;code dir=&quot;auto&quot;&gt;create_reminder&lt;/code&gt; for the nudge — one turn, three device services.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;permissions-still-belong-to-your-app&quot;&gt;Permissions still belong to your app&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The capability builders (&lt;code dir=&quot;auto&quot;&gt;AddContacts(...)&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;AddReminders(...)&lt;/code&gt;) are an allow-list for the &lt;em&gt;agent&lt;/em&gt; — they decide which operations the model can see. They are &lt;strong&gt;not&lt;/strong&gt; an OS permission prompt. The underlying platform permission must already be granted: call &lt;code dir=&quot;auto&quot;&gt;IContactStore.RequestAccess&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;INotificationManager.RequestAccess&lt;/code&gt;, or start a GPS listener from your app before you invoke the agent. The tools assume access is in place and return a clean error object if it isn’t.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;get-started&quot;&gt;Get started&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.Contacts.Extensions.AI&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.Notifications.Extensions.AI&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.Locations.Extensions.AI&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Full details on each: &lt;a href=&quot;https://www.shinylib.net/contactstore/ai-tools&quot;&gt;Contacts AI Tools&lt;/a&gt;, &lt;a href=&quot;https://www.shinylib.net/notifications/ai-tools&quot;&gt;Reminder AI Tools&lt;/a&gt;, and &lt;a href=&quot;https://www.shinylib.net/locations/ai-tools&quot;&gt;Location AI Tools&lt;/a&gt;. Same pattern as &lt;a href=&quot;https://www.shinylib.net/health/ai-tools&quot;&gt;Health AI Tools&lt;/a&gt; — opt-in, read-only by default, AOT-safe, and now your agent has hands.&lt;/p&gt;</content:encoded><category>contacts</category><category>notifications</category><category>locations</category><category>release</category><category>AI</category></item><item><title>A NEW CameraView to Rule Them All</title><link>https://www.shinylib.net/blog/2026/06/cameraview/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/06/cameraview/</guid><description>A cross-platform CameraView for .NET MAUI (iOS, Android, Windows, macOS AppKit) and Blazor WebAssembly — live preview, photo/video capture, 11 live color filters, and a pluggable frame-analysis pipeline with barcode, face, motion, OCR, and strongly-typed document analyzers (invoice, receipt, driver&apos;s license, health card, credit card, passport).</description><pubDate>Mon, 15 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Maui.Controls.Camera&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Maui.Controls.Camera?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.Maui.Controls.Camera&quot;&gt;&lt;/a&gt;
&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Blazor.Controls.Camera&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Blazor.Controls.Camera?style=for-the-badge&amp;#x26;logo=nuget&quot; alt=&quot;NuGet package Shiny.Blazor.Controls.Camera&quot;&gt;&lt;/a&gt;
&lt;p&gt;A camera control sounds simple until you ship one. You want live preview, zoom, torch, lens selection, photo and video capture — fine. Then you want to &lt;em&gt;scan a barcode&lt;/em&gt;, &lt;em&gt;box a face&lt;/em&gt;, &lt;em&gt;read a receipt&lt;/em&gt;, &lt;em&gt;parse a driver’s license&lt;/em&gt;, apply a live filter, and have it all run on iOS, Android, Windows, &lt;strong&gt;macOS AppKit&lt;/strong&gt;, and &lt;strong&gt;Blazor WebAssembly&lt;/strong&gt; without rewriting the pipeline five times.&lt;/p&gt;
&lt;p&gt;That’s what &lt;code dir=&quot;auto&quot;&gt;CameraView&lt;/code&gt; is: one control, one API surface, every platform — with a pluggable frame-analysis pipeline bolted on.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.Maui.Controls.Camera&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;UseShinyControls&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;UseShinyCamera&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;xmlns:cam=&quot;http://shiny.net/maui/camera&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;cam:CameraView&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;x:Name&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Camera&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                &lt;/span&gt;&lt;span&gt;Facing&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Back&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                &lt;/span&gt;&lt;span&gt;ScaleMode&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;AspectFill&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                &lt;/span&gt;&lt;span&gt;Filter&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;None&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The preview auto-starts (&lt;code dir=&quot;auto&quot;&gt;IsActive&lt;/code&gt; defaults &lt;code dir=&quot;auto&quot;&gt;true&lt;/code&gt;) and the control requests camera permission itself — you handle a denial (or any error) through &lt;code dir=&quot;auto&quot;&gt;CameraError&lt;/code&gt;, and toggle &lt;code dir=&quot;auto&quot;&gt;IsActive&lt;/code&gt; for lifecycle:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.Camera.CameraError &lt;/span&gt;&lt;span&gt;+=&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;_&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;e&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; status &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; e.Message;   &lt;/span&gt;&lt;span&gt;// e.g. &quot;Camera permission denied&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;protected&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;override&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;void&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;OnDisappearing&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;base&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;OnDisappearing&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.Camera.IsActive &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;false&lt;/span&gt;&lt;span&gt;;   &lt;/span&gt;&lt;span&gt;// release the camera off-screen&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// JPEG bytes — the current Filter is baked in, so the photo matches the preview&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;CameraPhoto&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;photo&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.Camera.&lt;/span&gt;&lt;span&gt;CapturePhotoAsync&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Video (audio optional). Recorded video records the raw, unfiltered feed.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.Camera.&lt;/span&gt;&lt;span&gt;StartVideoRecordingAsync&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;VideoRecordingOptions&lt;/span&gt;&lt;span&gt; { IncludeAudio &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;CameraVideo&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;video&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.Camera.&lt;/span&gt;&lt;span&gt;StopVideoRecordingAsync&lt;/span&gt;&lt;span&gt;();   &lt;/span&gt;&lt;span&gt;// a file path you can stream&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;provider-reach&quot;&gt;Provider reach&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The same &lt;code dir=&quot;auto&quot;&gt;CameraView&lt;/code&gt; lights up on five hosts, each over the platform’s native stack:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Host&lt;/th&gt;
&lt;th&gt;Backend&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Apple (iOS / Mac Catalyst)&lt;/td&gt;
&lt;td&gt;AVFoundation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;macOS (AppKit)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;AVFoundation over AppKit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;CameraX (min SDK 23)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Windows&lt;/td&gt;
&lt;td&gt;Media Capture&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blazor WebAssembly&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;getUserMedia&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;MediaRecorder&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;BarcodeDetector&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Facing&lt;/code&gt; picks a lens by position (&lt;code dir=&quot;auto&quot;&gt;Back&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;Front&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;External&lt;/code&gt;); &lt;code dir=&quot;auto&quot;&gt;CameraId&lt;/code&gt; pins an exact device — which is how you choose between multiple back lenses on a phone or a specific USB webcam on macOS:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;IReadOnlyList&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;CameraInfo&lt;/span&gt;&lt;span&gt;&gt; &lt;/span&gt;&lt;span&gt;cameras&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.Camera.&lt;/span&gt;&lt;span&gt;GetAvailableCamerasAsync&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.Camera.CameraId &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; cameras.&lt;/span&gt;&lt;span&gt;First&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; c.Name.&lt;/span&gt;&lt;span&gt;Contains&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;USB&quot;&lt;/span&gt;&lt;span&gt;)).Id;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;live-color-filters&quot;&gt;Live color filters&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Set &lt;code dir=&quot;auto&quot;&gt;Filter&lt;/code&gt; and the look is applied to the &lt;strong&gt;live preview and baked into captured photos&lt;/strong&gt;, so what you see is what you get:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.Camera.Filter &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; CameraFilter.Noir;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Eleven filters ship — &lt;code dir=&quot;auto&quot;&gt;Mono&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Noir&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Sepia&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Invert&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Vivid&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Cool&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Warm&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Fade&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Chrome&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Instant&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Tonal&lt;/code&gt; — plus &lt;code dir=&quot;auto&quot;&gt;None&lt;/code&gt;. A couple of honest platform caveats: recorded &lt;strong&gt;video&lt;/strong&gt; records the unfiltered feed, the Android live-preview filter needs API 31+ (it uses &lt;code dir=&quot;auto&quot;&gt;RenderEffect&lt;/code&gt;; captured photos are still filtered on older Android), and Windows has no live filter.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;frame-analysis--the-differentiator&quot;&gt;Frame analysis — the differentiator&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This is where &lt;code dir=&quot;auto&quot;&gt;CameraView&lt;/code&gt; stops being “a camera” and becomes a platform. Assign an &lt;code dir=&quot;auto&quot;&gt;IFrameAnalyzer&lt;/code&gt; to &lt;code dir=&quot;auto&quot;&gt;Camera.Analyzer&lt;/code&gt; and the pipeline streams frames &lt;strong&gt;off the UI thread&lt;/strong&gt; with &lt;strong&gt;drop-on-busy back-pressure&lt;/strong&gt; (only one frame in flight — it never backs up). It’s &lt;em&gt;one&lt;/em&gt; analyzer at a time, and you &lt;strong&gt;set or swap it freely while the camera is running&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Every analyzer has &lt;strong&gt;two channels&lt;/strong&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;strongly-typed event&lt;/strong&gt; carrying the semantic result (the barcodes, the faces, the recognized text, the structured document).&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;return value&lt;/strong&gt; of its analysis: styled &lt;code dir=&quot;auto&quot;&gt;OverlayBox&lt;/code&gt;es to draw. A returned set persists until the analyzer returns a different set (replace) or &lt;code dir=&quot;auto&quot;&gt;null&lt;/code&gt; (clear).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The boxes draw live the whole time the analyzer runs; the typed event is &lt;strong&gt;gated on a scan&lt;/strong&gt;. The analyzer stays quiet until you &lt;em&gt;arm&lt;/em&gt; it — &lt;code dir=&quot;auto&quot;&gt;Camera.Scan()&lt;/code&gt;, or bind a shutter button / Fab to &lt;code dir=&quot;auto&quot;&gt;Camera.ScanCommand&lt;/code&gt; — and then it delivers its &lt;strong&gt;next&lt;/strong&gt; confirmed detection exactly once. Single-shot is the default, which is usually what you want (“point, tap, get the code”). To keep scanning, give the analyzer an &lt;code dir=&quot;auto&quot;&gt;OnDetected&lt;/code&gt; handler that returns &lt;code dir=&quot;auto&quot;&gt;true&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;barcode&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BarcodeAnalyzer&lt;/span&gt;&lt;span&gt;();                 &lt;/span&gt;&lt;span&gt;// native — Apple Vision (iOS/macOS) / Android MLKit&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;barcode.BarcodesDetected &lt;/span&gt;&lt;span&gt;+=&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;_&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;e&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;status &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;$&quot;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;First&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Format&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;First&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Value&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;;    &lt;/span&gt;&lt;span&gt;// e.Barcodes holds every code in the frame&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Camera.Analyzer &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; barcode;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Camera.&lt;/span&gt;&lt;span&gt;Scan&lt;/span&gt;&lt;span&gt;();                                        &lt;/span&gt;&lt;span&gt;// arm — deliver the next decode, once&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// continuous: stay armed as long as the handler returns true&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;barcode.OnDetected &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;e&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Save&lt;/span&gt;&lt;span&gt;(e.First.Value); &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;; };&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Swapping the analyzer is just an assignment — the running session picks it up:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Camera.Analyzer &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;FaceAnalyzer&lt;/span&gt;&lt;span&gt;();                 &lt;/span&gt;&lt;span&gt;// Apple Vision / Android MLKit / Windows.FaceAnalysis&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Camera.Analyzer &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;MotionAnalyzer&lt;/span&gt;&lt;span&gt;();               &lt;/span&gt;&lt;span&gt;// pure-managed frame differencing — every platform&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Events are marshalled to the UI thread for you, so handlers can touch UI directly.&lt;/p&gt;
&lt;p&gt;To switch the analyzer off &lt;em&gt;without&lt;/em&gt; dropping it (keeping its bindings and internal state), set &lt;code dir=&quot;auto&quot;&gt;FrameAnalyzer.IsEnabled = false&lt;/code&gt; — it resumes instantly when re-enabled, and while disabled the camera behaves as if it had no analyzer (so on Android you can record video). That’s distinct from &lt;code dir=&quot;auto&quot;&gt;ShowBoundingBox = false&lt;/code&gt; (run and deliver, just draw nothing).&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;mvvm-declare-the-analyzer-in-xaml-bind-commands&quot;&gt;MVVM: declare the analyzer in XAML, bind commands&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Analyzers are &lt;code dir=&quot;auto&quot;&gt;BindableObject&lt;/code&gt;s, and &lt;code dir=&quot;auto&quot;&gt;CameraView&lt;/code&gt;’s content property is &lt;code dir=&quot;auto&quot;&gt;Analyzer&lt;/code&gt;, so you can declare it inline under the one &lt;code dir=&quot;auto&quot;&gt;cam:&lt;/code&gt; prefix and bind its &lt;code dir=&quot;auto&quot;&gt;…Command&lt;/code&gt; to your ViewModel — fired on the UI thread with the same args as the event. Bind a shutter button to the camera’s &lt;code dir=&quot;auto&quot;&gt;ScanCommand&lt;/code&gt; to arm it:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Grid&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;cam:CameraView&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;x:Name&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Camera&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Facing&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Back&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Filter&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Chrome&quot;&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;cam:BarcodeAnalyzer&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BarcodesDetectedCommand&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;{Binding ScanResultCommand}&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;cam:CameraView&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Button&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Text&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Scan&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;Command&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;{Binding Source={x:Reference Camera}, Path=ScanCommand}&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;VerticalOptions&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;End&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;Grid&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;bounding-boxes-for-free&quot;&gt;Bounding boxes for free&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Drop a &lt;code dir=&quot;auto&quot;&gt;CameraOverlayView&lt;/code&gt; over the &lt;code dir=&quot;auto&quot;&gt;CameraView&lt;/code&gt; in the same &lt;code dir=&quot;auto&quot;&gt;Grid&lt;/code&gt; cell — it auto-subscribes and redraws:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Grid&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;cam:CameraView&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;x:Name&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;Camera&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ScaleMode&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;AspectFill&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;cam:CameraOverlayView&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Camera&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;{x:Reference Camera}&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;InputTransparent&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;True&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;Grid&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;OverlayBox.Rect&lt;/code&gt; is normalized (0..1), upright, and mirror-corrected — the overlay maps it into view space, so you never deal in raw pixels. Set the analyzer’s &lt;code dir=&quot;auto&quot;&gt;ScanWindow&lt;/code&gt; to a normalized rectangle to restrict detection to a band: only detections centered inside it are reported and drawn, and the overlay dims everything outside it and frames a viewfinder reticle. Want custom styling? Each analyzer exposes an &lt;code dir=&quot;auto&quot;&gt;OverlayProvider&lt;/code&gt; to return exactly the boxes you want (or &lt;code dir=&quot;auto&quot;&gt;null&lt;/code&gt; for none):&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;barcode.OverlayProvider &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;e&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; e.First.Value.&lt;/span&gt;&lt;span&gt;StartsWith&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;OK&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;?&lt;/span&gt;&lt;span&gt; [ &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;OverlayBox&lt;/span&gt;&lt;span&gt;(e.First.BoundingBox, Colors.Lime, e.First.Value) ]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;MotionAnalyzer&lt;/code&gt; is a nice example of the pipeline’s range: it clusters movement into &lt;strong&gt;separate regions&lt;/strong&gt;, so motion in two spots yields two boxes rather than one box spanning both — handy for a security-cam view. Tune it with &lt;code dir=&quot;auto&quot;&gt;PixelThreshold&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;AreaThreshold&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;SampleStride&lt;/code&gt;, and &lt;code dir=&quot;auto&quot;&gt;GridColumns&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;CellThreshold&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;document-analyzers--structured-data-not-just-text&quot;&gt;Document analyzers — structured data, not just text&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;Shiny.Maui.Controls.Camera.Documents&lt;/code&gt; package turns the camera into a scanner that hands you &lt;strong&gt;strongly-typed records&lt;/strong&gt;, not raw strings. Every document type is its own analyzer with its own typed &lt;code dir=&quot;auto&quot;&gt;DocumentDetected&lt;/code&gt; event, and every payload is a record with &lt;strong&gt;nullable fields&lt;/strong&gt; — only what was actually found is set. Like every analyzer, delivery is arm-gated, so call &lt;code dir=&quot;auto&quot;&gt;Camera.Scan()&lt;/code&gt; (or bind &lt;code dir=&quot;auto&quot;&gt;ScanCommand&lt;/code&gt;) to capture the next read.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.Maui.Controls.Camera.Documents&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;invoice&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;InvoiceAnalyzer&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;invoice.DocumentDetected &lt;/span&gt;&lt;span&gt;+=&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;_&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;e&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;Invoice&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;doc&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; e.Document;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;status &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;$&quot;Invoice &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;doc&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Number&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; — total &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;doc&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Total&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;doc&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Lines&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Count&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; line(s)&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;};&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Camera.Analyzer &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; invoice;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Camera.&lt;/span&gt;&lt;span&gt;Scan&lt;/span&gt;&lt;span&gt;();                                        &lt;/span&gt;&lt;span&gt;// arm — delivers the next parsed invoice&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;license&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;DriversLicenseAnalyzer&lt;/span&gt;&lt;span&gt;();           &lt;/span&gt;&lt;span&gt;// PDF417 + AAMVA — deterministic, no ML&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;license.DocumentDetected &lt;/span&gt;&lt;span&gt;+=&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;_&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;e&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;status &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;$&quot;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;FirstName&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;LastName&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; — &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Number&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;What ships:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;InvoiceAnalyzer&lt;/code&gt;&lt;/strong&gt; → &lt;code dir=&quot;auto&quot;&gt;Invoice&lt;/code&gt; with order lines in &lt;code dir=&quot;auto&quot;&gt;.Lines&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;ReceiptAnalyzer&lt;/code&gt;&lt;/strong&gt; → &lt;code dir=&quot;auto&quot;&gt;Receipt&lt;/code&gt; with purchased line items (&lt;code dir=&quot;auto&quot;&gt;.Lines&lt;/code&gt;), a &lt;strong&gt;per-tax breakdown&lt;/strong&gt; (&lt;code dir=&quot;auto&quot;&gt;.Taxes&lt;/code&gt;), and subtotal / tip / discount / total, plus best-effort payment method, last-4, currency, date/time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;DriversLicenseAnalyzer&lt;/code&gt;&lt;/strong&gt; → &lt;code dir=&quot;auto&quot;&gt;DriversLicense&lt;/code&gt;, decoded from the &lt;strong&gt;PDF417 barcode&lt;/strong&gt; on the back and parsed against the &lt;strong&gt;AAMVA&lt;/strong&gt; standard — fully deterministic. Works for US states and the Canadian provinces that emit an AAMVA PDF417 (BC, AB, SK, MB, NS, NB, PEI, NL); dates auto-switch to Canadian &lt;code dir=&quot;auto&quot;&gt;CCYYMMDD&lt;/code&gt; order and the province surfaces as &lt;code dir=&quot;auto&quot;&gt;Jurisdiction&lt;/code&gt;. (Ontario and Quebec licences carry no PDF417, so they don’t scan — use a custom OCR parser for those.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;HealthCardAnalyzer&lt;/code&gt;&lt;/strong&gt; → &lt;code dir=&quot;auto&quot;&gt;HealthCard&lt;/code&gt;, OCR tuned for &lt;strong&gt;Canadian&lt;/strong&gt; cards: it detects the issuing province from on-card keywords and applies that province’s number format — Quebec/RAMQ, Ontario/OHIP, BC PHN, Alberta/AHCIP, etc. — surfacing &lt;code dir=&quot;auto&quot;&gt;Province&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;Issuer&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;CreditCardAnalyzer&lt;/code&gt;&lt;/strong&gt; → &lt;code dir=&quot;auto&quot;&gt;CreditCard&lt;/code&gt;: brand (&lt;code dir=&quot;auto&quot;&gt;Visa&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Mastercard&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Amex&lt;/code&gt;/…) and number validity from the IIN prefix + Luhn are deterministic; name/expiry are best-effort OCR. The CVV lives on the back, so a front scan almost always leaves it &lt;code dir=&quot;auto&quot;&gt;null&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;PassportAnalyzer&lt;/code&gt;&lt;/strong&gt; → &lt;code dir=&quot;auto&quot;&gt;Passport&lt;/code&gt;, parsed from the &lt;strong&gt;MRZ&lt;/strong&gt; (the two &lt;code dir=&quot;auto&quot;&gt;&amp;#x3C;&amp;#x3C;&amp;#x3C;&lt;/code&gt; lines, ICAO TD3) — deterministic.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The deterministic ones (driver’s license PDF417/AAMVA, passport MRZ, credit-card IIN/Luhn) are exactly that — no ML guesswork. The rule-based ones (invoice, receipt, health card) are best-effort, and when you need more accuracy you swap in your own parser without writing a new analyzer:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;InvoiceAnalyzer&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;MyInvoiceParser&lt;/span&gt;&lt;span&gt;());   &lt;/span&gt;&lt;span&gt;// MyInvoiceParser : IDocumentParser&amp;#x3C;Invoice&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A document analyzer accumulates reads across a few frames before it commits (&lt;code dir=&quot;auto&quot;&gt;AccumulationFrames&lt;/code&gt;, default 5) — handy when one frame catches half the card and the next catches the rest — and resets after a run of empty frames (&lt;code dir=&quot;auto&quot;&gt;ResetAfterEmptyFrames&lt;/code&gt;).&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;roll-your-own-document-type&quot;&gt;Roll your own document type&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Need to scan something we don’t ship — a business card, a shipping label, a lab form? Derive from &lt;code dir=&quot;auto&quot;&gt;DocumentAnalyzer&amp;#x3C;TDocument&gt;&lt;/code&gt; and supply an &lt;code dir=&quot;auto&quot;&gt;IDocumentParser&amp;#x3C;TDocument&gt;&lt;/code&gt;. The base class runs the shared OCR recognizer, calls your parser, raises the typed event (and command) on the UI thread, draws the boxes, and honours arming / &lt;code dir=&quot;auto&quot;&gt;IsEnabled&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;ShowBoundingBox&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;OverlayProvider&lt;/code&gt;. You write the payload and the parse rules — nothing else:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;record&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BusinessCard&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;? &lt;/span&gt;&lt;span&gt;Name&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;? &lt;/span&gt;&lt;span&gt;Company&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;? &lt;/span&gt;&lt;span&gt;Email&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;? &lt;/span&gt;&lt;span&gt;Phone&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;IReadOnlyList&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;DocumentField&lt;/span&gt;&lt;span&gt;&gt; &lt;/span&gt;&lt;span&gt;Fields&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sealed&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;partial&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BusinessCardParser&lt;/span&gt;&lt;span&gt; : &lt;/span&gt;&lt;span&gt;IDocumentParser&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;BusinessCard&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;GeneratedRegex&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;@&quot;[\w.+-]+@[\w-]+\.[\w.-]+&quot;&lt;/span&gt;&lt;span&gt;)] &lt;/span&gt;&lt;span&gt;private&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;static&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;partial&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Regex&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Email&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;bool&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TryParse&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;IReadOnlyList&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;RecognizedText&lt;/span&gt;&lt;span&gt;&gt; &lt;/span&gt;&lt;span&gt;text&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;out&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BusinessCard&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;document&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;out&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;IReadOnlyList&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;OverlayBox&lt;/span&gt;&lt;span&gt;&gt; &lt;/span&gt;&lt;span&gt;boxes&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;document &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;; boxes &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; [];&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;emailLine&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; text.&lt;/span&gt;&lt;span&gt;FirstOrDefault&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Email&lt;/span&gt;&lt;span&gt;().&lt;/span&gt;&lt;span&gt;IsMatch&lt;/span&gt;&lt;span&gt;(t.Text));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (emailLine &lt;/span&gt;&lt;span&gt;is&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;false&lt;/span&gt;&lt;span&gt;;   &lt;/span&gt;&lt;span&gt;// cheap &quot;is this my document?&quot; check — bail fast&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;email&lt;/span&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Email&lt;/span&gt;&lt;span&gt;().&lt;/span&gt;&lt;span&gt;Match&lt;/span&gt;&lt;span&gt;(emailLine.Text).Value;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;   &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; text.&lt;/span&gt;&lt;span&gt;FirstOrDefault&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;?&lt;/span&gt;&lt;span&gt;.Text;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;fields&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;List&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;DocumentField&lt;/span&gt;&lt;span&gt;&gt; { &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Name&quot;&lt;/span&gt;&lt;span&gt;, name, text.&lt;/span&gt;&lt;span&gt;FirstOrDefault&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;?&lt;/span&gt;&lt;span&gt;.BoundingBox), &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Email&quot;&lt;/span&gt;&lt;span&gt;, email, emailLine.BoundingBox) };&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;document &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BusinessCard&lt;/span&gt;&lt;span&gt;(name, &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;, email, &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;, fields);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;boxes &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; fields.&lt;/span&gt;&lt;span&gt;Where&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;f&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; f.Bounds &lt;/span&gt;&lt;span&gt;is&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;not&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;Select&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;f&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;OverlayBox&lt;/span&gt;&lt;span&gt;(f.Bounds&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;.Value, Colors.Lime, f.Label)).&lt;/span&gt;&lt;span&gt;ToList&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sealed&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BusinessCardAnalyzer&lt;/span&gt;&lt;span&gt; : &lt;/span&gt;&lt;span&gt;DocumentAnalyzer&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;BusinessCard&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BusinessCardAnalyzer&lt;/span&gt;&lt;span&gt;() : &lt;/span&gt;&lt;span&gt;base&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BusinessCardParser&lt;/span&gt;&lt;span&gt;()) { }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;override&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Id&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;myapp.camera.businesscard&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Because the parser is just an interface, an LLM- or service-backed parser is perfectly fine — the analyzer drops frames while one parse is in flight, so a slow remote call won’t pile up.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-analyzer-line-up&quot;&gt;The analyzer line-up&lt;/h2&gt;&lt;/div&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Analyzer&lt;/th&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Platforms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;BarcodeAnalyzer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;.Camera.Barcode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Apple Vision / Android MLKit (native)&lt;/td&gt;
&lt;td&gt;iOS, Android, macOS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;FaceAnalyzer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;.Camera.Face&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Vision / MLKit / Windows.FaceAnalysis&lt;/td&gt;
&lt;td&gt;iOS, Android, Windows, macOS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;MotionAnalyzer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;.Camera.Motion&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;managed frame differencing&lt;/td&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;OcrAnalyzer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;.Camera.Ocr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;native OCR (Vision / MLKit / Windows.Media.Ocr)&lt;/td&gt;
&lt;td&gt;iOS, Android, Windows, macOS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Document analyzers&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;.Camera.Documents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OCR + rules / native PDF417 / MRZ&lt;/td&gt;
&lt;td&gt;iOS, Android, Windows, macOS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Add only the packages you need. &lt;code dir=&quot;auto&quot;&gt;MotionAnalyzer&lt;/code&gt; is pure-managed and runs everywhere; the barcode / face / OCR / document analyzers ride native ML/vision, so they produce results on the device platforms (not bare &lt;code dir=&quot;auto&quot;&gt;net10.0&lt;/code&gt;) — barcode and the PDF417-based driver’s license are iOS / macOS / Android.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;blazor&quot;&gt;Blazor&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The Blazor component mirrors the MAUI control with the same concepts in component clothing — including the single &lt;code dir=&quot;auto&quot;&gt;Analyzer&lt;/code&gt; and the arm-to-scan model:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;CameraView&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;@ref&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;camera&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;Facing&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;CameraFacing.Back&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;Analyzer&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;barcode&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;ShowOverlay&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;true&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;Filter&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;filter&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;BarcodesDetected&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;OnBarcodes&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;OnError&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;m =&gt; status = m&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;Style&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;width:100%;height:100%;&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;@code&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;CameraView&lt;/span&gt;&lt;span&gt;? &lt;/span&gt;&lt;span&gt;camera&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;readonly&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BarcodeAnalyzer&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;barcode&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;CameraFilter&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;filter&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; CameraFilter.None;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;// await the next code (the gated equivalent of MAUI&apos;s Scan)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Scan&lt;/span&gt;&lt;span&gt;()  { &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;b&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; camera&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;RequestBarcodeAsync&lt;/span&gt;&lt;span&gt;(); status &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;$&quot;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;b&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Format&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;b&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Value&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;void&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;OnBarcodes&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;IReadOnlyList&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;CameraBarcode&lt;/span&gt;&lt;span&gt;&gt; &lt;/span&gt;&lt;span&gt;codes&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; status &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;$&quot;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;codes&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;].&lt;/span&gt;&lt;span&gt;Format&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;codes&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;].&lt;/span&gt;&lt;span&gt;Value&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Photo&lt;/span&gt;&lt;span&gt;() { &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;jpeg&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; camera&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;CapturePhotoAsync&lt;/span&gt;&lt;span&gt;(); }          &lt;/span&gt;&lt;span&gt;// byte[], filtered to match preview&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Rec&lt;/span&gt;&lt;span&gt;()   { &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; camera&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;StartRecordingAsync&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;includeAudio&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;); }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Stop&lt;/span&gt;&lt;span&gt;()  { &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;webm&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; camera&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;StopRecordingAsync&lt;/span&gt;&lt;span&gt;(); }         &lt;/span&gt;&lt;span&gt;// byte[] WebM&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Preview, zoom, filters (CSS), photo, and video capture work in every browser. Barcode scanning uses the browser’s native &lt;code dir=&quot;auto&quot;&gt;BarcodeDetector&lt;/code&gt; (Chromium today): assign a &lt;code dir=&quot;auto&quot;&gt;BarcodeAnalyzer&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;await RequestBarcodeAsync()&lt;/code&gt; for the next code, or handle &lt;code dir=&quot;auto&quot;&gt;BarcodesDetected&lt;/code&gt; for every code in a frame while a request is outstanding. On Firefox/Safari &lt;code dir=&quot;auto&quot;&gt;OnError&lt;/code&gt; fires once and preview continues, so feature-detect if you need universal coverage. Face/motion/OCR/document analyzers are MAUI-native.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;a-few-things-to-know&quot;&gt;A few things to know&lt;/h2&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Permissions are yours to declare&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;NSCameraUsageDescription&lt;/code&gt; on Apple (omitting it crashes iOS instantly), the &lt;code dir=&quot;auto&quot;&gt;CAMERA&lt;/code&gt; permission on Android, the &lt;code dir=&quot;auto&quot;&gt;webcam&lt;/code&gt; capability on Windows. &lt;code dir=&quot;auto&quot;&gt;getUserMedia&lt;/code&gt; needs a secure context (HTTPS or &lt;code dir=&quot;auto&quot;&gt;localhost&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Android: video vs. analyzers&lt;/strong&gt; — CameraX caps concurrent use-cases, so the camera binds &lt;em&gt;either&lt;/em&gt; image analysis (while an analyzer is enabled) &lt;em&gt;or&lt;/em&gt; video capture. Clear &lt;code dir=&quot;auto&quot;&gt;Analyzer&lt;/code&gt; or set &lt;code dir=&quot;auto&quot;&gt;IsEnabled = false&lt;/code&gt; to record; &lt;code dir=&quot;auto&quot;&gt;StartVideoRecordingAsync&lt;/code&gt; throws a clear error otherwise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don’t gate startup on &lt;code dir=&quot;auto&quot;&gt;RequestPermissionAsync()&lt;/code&gt;&lt;/strong&gt; — it routes through the handler and returns &lt;code dir=&quot;auto&quot;&gt;false&lt;/code&gt; before the view is connected (e.g. in &lt;code dir=&quot;auto&quot;&gt;OnAppearing&lt;/code&gt; on first show), which looks like a denial. Rely on auto-start + &lt;code dir=&quot;auto&quot;&gt;CameraError&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Grab the packages, call &lt;code dir=&quot;auto&quot;&gt;.UseShinyCamera()&lt;/code&gt;, and you’ve got a real camera — preview to structured documents — on every platform you ship to. Full docs are in the &lt;a href=&quot;https://shinylib.net/controls/cameraview/&quot;&gt;CameraView guide&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>Feature</category><category>Controls</category></item><item><title>Shiny Health 2.0 — The Ultimate Cross-Platform Health Library</title><link>https://www.shinylib.net/blog/2026/06/shiny-health-v2/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/06/shiny-health-v2/</guid><description>One IHealthService for Apple HealthKit and Android Health Connect — 30+ data types across activity, body, vitals, nutrition, reproductive/cycle tracking, and workouts, real-time observation, and a brand-new Microsoft.Extensions.AI tool surface for LLM agents.</description><pubDate>Mon, 15 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Health&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Health?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Health&quot; alt=&quot;NuGet package Shiny.Health&quot;&gt;&lt;/a&gt;
&lt;p&gt;Reading health data on .NET MAUI means two completely different worlds: Apple &lt;strong&gt;HealthKit&lt;/strong&gt; with its &lt;code dir=&quot;auto&quot;&gt;HKQuantityType&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;HKCategoryType&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;HKCorrelation&lt;/code&gt; zoo, and Android &lt;strong&gt;Health Connect&lt;/strong&gt; with its Kotlin record types, coroutine APIs, and per-record permissions. Shiny Health collapses both behind a single &lt;code dir=&quot;auto&quot;&gt;IHealthService&lt;/code&gt; — and &lt;strong&gt;2.0&lt;/strong&gt; is a big one: 30+ cross-platform data types, dedicated records for the non-numeric stuff, and a new package that turns your health data into tools an LLM can call.&lt;/p&gt;
&lt;p&gt;This is our first proper write-up of the library, so let’s cover the whole thing.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;one-interface-two-platforms&quot;&gt;One Interface, Two Platforms&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.Health&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;static&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;MauiApp&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;CreateMauiApp&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;builder&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; MauiApp.&lt;/span&gt;&lt;span&gt;CreateBuilder&lt;/span&gt;&lt;span&gt;().&lt;/span&gt;&lt;span&gt;UseMauiApp&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;App&lt;/span&gt;&lt;span&gt;&gt;().&lt;/span&gt;&lt;span&gt;UseShiny&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddHealthIntegration&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; builder.&lt;/span&gt;&lt;span&gt;Build&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Inject &lt;code dir=&quot;auto&quot;&gt;IHealthService&lt;/code&gt;, request permissions, and query. Everything is &lt;code dir=&quot;auto&quot;&gt;async&lt;/code&gt;, cancellation-aware, and AOT-friendly (no reflection — the Android side bridges Kotlin coroutines through a hand-rolled &lt;code dir=&quot;auto&quot;&gt;IContinuation&lt;/code&gt;).&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;DashboardViewModel&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;IHealthService&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;health&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;LoadAsync&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;RequestPermissions&lt;/span&gt;&lt;span&gt;(DataType.StepCount, DataType.HeartRate, DataType.Calories);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;end&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; DateTimeOffset.Now;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;start&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; end.&lt;/span&gt;&lt;span&gt;AddDays&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;steps&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;GetStepCounts&lt;/span&gt;&lt;span&gt;(start, end, Interval.Hours)).&lt;/span&gt;&lt;span&gt;Sum&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.Value);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;hr&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;GetAverageHeartRate&lt;/span&gt;&lt;span&gt;(start, end, Interval.Hours)).&lt;/span&gt;&lt;span&gt;Average&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.Value);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;kcal&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;GetCalories&lt;/span&gt;&lt;span&gt;(start, end, Interval.Days)).&lt;/span&gt;&lt;span&gt;Sum&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.Value);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Numeric metrics are &lt;strong&gt;time-bucketed&lt;/strong&gt;: you pick &lt;code dir=&quot;auto&quot;&gt;Interval.Minutes&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Hours&lt;/code&gt;, or &lt;code dir=&quot;auto&quot;&gt;Days&lt;/code&gt; and get one result per bucket. Cumulative metrics (steps, calories, distance, hydration, sleep) you &lt;code dir=&quot;auto&quot;&gt;.Sum()&lt;/code&gt;; point-in-time metrics (heart rate, weight, body fat) you &lt;code dir=&quot;auto&quot;&gt;.Average()&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;30-data-types&quot;&gt;30+ Data Types&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;2.0 expanded the catalog dramatically. Numeric metrics — each with its own &lt;code dir=&quot;auto&quot;&gt;Get…&lt;/code&gt; method and full read/write/observe support:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Activity&lt;/strong&gt; — step count, distance, active &amp;#x26; basal energy, floors climbed, wheelchair pushes, speed, power&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Heart&lt;/strong&gt; — average heart rate, resting heart rate, heart rate variability&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Body&lt;/strong&gt; — weight, height, body fat %, lean body mass&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vitals&lt;/strong&gt; — blood pressure, oxygen saturation, blood glucose, body temperature, basal body temperature, respiratory rate, VO2 max&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lifestyle&lt;/strong&gt; — sleep duration, hydration&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Blood pressure is special — it carries two values, so it gets its own result type:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;bp&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;GetBloodPressure&lt;/span&gt;&lt;span&gt;(start, end, Interval.Days); &lt;/span&gt;&lt;span&gt;// BloodPressureResult&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;avgSystolic&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; bp.&lt;/span&gt;&lt;span&gt;Average&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.Systolic);   &lt;/span&gt;&lt;span&gt;// mmHg&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;avgDiastolic&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; bp.&lt;/span&gt;&lt;span&gt;Average&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x.Diastolic);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;reproductive--cycle-tracking&quot;&gt;Reproductive &amp;#x26; Cycle Tracking&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;These are categorical and event-based — no &lt;code dir=&quot;auto&quot;&gt;Interval&lt;/code&gt; bucketing, each with a dedicated record:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;RequestPermissions&lt;/span&gt;&lt;span&gt;(DataType.MenstruationFlow, DataType.OvulationTest);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;flow&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;GetMenstruationFlow&lt;/span&gt;&lt;span&gt;(start, end);     &lt;/span&gt;&lt;span&gt;// MenstrualFlow: None/Light/Medium/Heavy&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ovulation&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;GetOvulationTests&lt;/span&gt;&lt;span&gt;(start, end);  &lt;/span&gt;&lt;span&gt;// Positive/Negative/High/Inconclusive&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;mucus&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;GetCervicalMucus&lt;/span&gt;&lt;span&gt;(start, end);       &lt;/span&gt;&lt;span&gt;// Dry/Sticky/Creamy/Watery/EggWhite&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;activity&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;GetSexualActivity&lt;/span&gt;&lt;span&gt;(start, end);   &lt;/span&gt;&lt;span&gt;// Protected/Unprotected&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;spotting&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;GetIntermenstrualBleeding&lt;/span&gt;&lt;span&gt;(start, end);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// logging&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;Write&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;MenstruationFlowResult&lt;/span&gt;&lt;span&gt;(DateTimeOffset.Now, DateTimeOffset.Now, MenstrualFlow.Medium, &lt;/span&gt;&lt;span&gt;IsCycleStart&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;workouts--nutrition&quot;&gt;Workouts &amp;#x26; Nutrition&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;workouts&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;GetWorkouts&lt;/span&gt;&lt;span&gt;(start, end);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;foreach&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;w&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; workouts)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Console.&lt;/span&gt;&lt;span&gt;WriteLine&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;$&quot;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;w&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Workout&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;w&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;TotalEnergyKilocalories&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; kcal over &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;w&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;End&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;w&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Start&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;Write&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;WorkoutResult&lt;/span&gt;&lt;span&gt;(start, end, WorkoutType.Running, &lt;/span&gt;&lt;span&gt;TotalEnergyKilocalories&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;420&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;TotalDistanceMeters&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;7500&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;Write&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NutritionResult&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;DateTimeOffset.Now, DateTimeOffset.Now,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;Meal&lt;/span&gt;&lt;span&gt;: MealType.Lunch, &lt;/span&gt;&lt;span&gt;Name&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;Chicken &amp;#x26; rice&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;EnergyKilocalories&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;550&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;ProteinGrams&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;40&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;CarbohydratesGrams&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;60&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;TotalFatGrams&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;12&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;WorkoutType&lt;/code&gt; maps 21 activities that exist on &lt;strong&gt;both&lt;/strong&gt; platforms (running, cycling, swimming, strength training, HIIT, yoga…); anything unmapped reads back as &lt;code dir=&quot;auto&quot;&gt;Other&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;writing-data&quot;&gt;Writing Data&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Every numeric metric writes through &lt;code dir=&quot;auto&quot;&gt;NumericHealthResult&lt;/code&gt; — pass the &lt;code dir=&quot;auto&quot;&gt;DataType&lt;/code&gt; and value in its documented unit:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;RequestPermissions&lt;/span&gt;&lt;span&gt;(PermissionType.Write, DataType.Weight, DataType.Hydration);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;Write&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NumericHealthResult&lt;/span&gt;&lt;span&gt;(DataType.Weight, DateTimeOffset.Now, DateTimeOffset.Now, &lt;/span&gt;&lt;span&gt;75.0&lt;/span&gt;&lt;span&gt;));   &lt;/span&gt;&lt;span&gt;// kg&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;Write&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NumericHealthResult&lt;/span&gt;&lt;span&gt;(DataType.Hydration, start, end, &lt;/span&gt;&lt;span&gt;0.5&lt;/span&gt;&lt;span&gt;));                              &lt;/span&gt;&lt;span&gt;// liters&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;Write&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BloodPressureResult&lt;/span&gt;&lt;span&gt;(DateTimeOffset.Now, DateTimeOffset.Now, &lt;/span&gt;&lt;span&gt;120&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;80&lt;/span&gt;&lt;span&gt;));                  &lt;/span&gt;&lt;span&gt;// mmHg&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Permissions are granular: &lt;code dir=&quot;auto&quot;&gt;PermissionType.Read&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Write&lt;/code&gt;, or &lt;code dir=&quot;auto&quot;&gt;ReadWrite&lt;/code&gt;, requestable uniformly or per-metric in a single call.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;real-time-observation&quot;&gt;Real-Time Observation&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Observe&lt;/code&gt; streams new samples as they’re recorded via &lt;code dir=&quot;auto&quot;&gt;IAsyncEnumerable&amp;#x3C;HealthResult&gt;&lt;/code&gt; — push-based &lt;code dir=&quot;auto&quot;&gt;HKAnchoredObjectQuery&lt;/code&gt; on iOS, Health Connect change-token polling on Android (interval configurable):&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;using&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;cts&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;CancellationTokenSource&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;foreach&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;result&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; health.&lt;/span&gt;&lt;span&gt;Observe&lt;/span&gt;&lt;span&gt;(DataType.HeartRate, &lt;/span&gt;&lt;span&gt;cancelToken&lt;/span&gt;&lt;span&gt;: cts.Token))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (result &lt;/span&gt;&lt;span&gt;is&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NumericHealthResult&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Console.&lt;/span&gt;&lt;span&gt;WriteLine&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;$&quot;❤️ &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Value&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; bpm at &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;n&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Start&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt;T&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;new-in-20-ai-tools&quot;&gt;New in 2.0: AI Tools&lt;/h2&gt;&lt;/div&gt;
&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Health.Extensions.AI&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Health.Extensions.AI?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Health.Extensions.AI&quot; alt=&quot;NuGet package Shiny.Health.Extensions.AI&quot;&gt;&lt;/a&gt;
&lt;p&gt;This is the headline feature. &lt;code dir=&quot;auto&quot;&gt;Shiny.Health.Extensions.AI&lt;/code&gt; exposes &lt;code dir=&quot;auto&quot;&gt;IHealthService&lt;/code&gt; as &lt;a href=&quot;https://learn.microsoft.com/dotnet/ai/&quot;&gt;&lt;code dir=&quot;auto&quot;&gt;Microsoft.Extensions.AI&lt;/code&gt;&lt;/a&gt; tool functions, so an LLM agent can answer “How did I sleep last week?” or “Log a 45-minute run” by calling your health store directly.&lt;/p&gt;
&lt;p&gt;It uses a small set of &lt;strong&gt;parameterized&lt;/strong&gt; tools — one &lt;code dir=&quot;auto&quot;&gt;get_health_metric&lt;/code&gt; tool covers all numeric metrics via a &lt;code dir=&quot;auto&quot;&gt;metric&lt;/code&gt; enum argument, instead of 25 separate tools — which keeps the model’s tool list short and its selection accurate. Read-only by default; write is opt-in per area.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.Health.Extensions.AI&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddHealthIntegration&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddHealthAITools&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;tools&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; tools&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddAllMetrics&lt;/span&gt;&lt;span&gt;()                                          &lt;/span&gt;&lt;span&gt;// read every numeric metric&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddMetric&lt;/span&gt;&lt;span&gt;(DataType.Weight, HealthAICapabilities.ReadWrite)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddBloodPressure&lt;/span&gt;&lt;span&gt;(HealthAICapabilities.ReadWrite)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddCycleTracking&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddWorkouts&lt;/span&gt;&lt;span&gt;(HealthAICapabilities.ReadWrite)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddNutrition&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// hand the tools to any IChatClient&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;tools&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; sp.&lt;/span&gt;&lt;span&gt;GetRequiredService&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;HealthAITools&lt;/span&gt;&lt;span&gt;&gt;().Tools;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;response&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; chatClient.&lt;/span&gt;&lt;span&gt;GetResponseAsync&lt;/span&gt;&lt;span&gt;(messages, &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ChatOptions&lt;/span&gt;&lt;span&gt; { Tools &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;..&lt;/span&gt;&lt;span&gt; tools] });&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Only the areas you opt-in to are visible to the model, and enum arguments are constrained to exactly what you allowed. The whole thing is &lt;code dir=&quot;auto&quot;&gt;IsAotCompatible&lt;/code&gt; — schemas are hand-built and results are emitted as &lt;code dir=&quot;auto&quot;&gt;JsonNode&lt;/code&gt;, so there’s no reflection in the tool path. Full details on the &lt;a href=&quot;https://www.shinylib.net/health/ai-tools&quot;&gt;AI Tools&lt;/a&gt; page.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;honest-about-the-platforms&quot;&gt;Honest About the Platforms&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A unified API shouldn’t pretend the platforms are identical. Where they differ, we document it rather than hide it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Heart rate variability&lt;/strong&gt; is &lt;strong&gt;SDNN&lt;/strong&gt; on iOS and &lt;strong&gt;RMSSD&lt;/strong&gt; on Android — both milliseconds, but different computations, so don’t compare the numbers across platforms.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Speed/Power&lt;/strong&gt; are generic on Health Connect; HealthKit has no generic equivalents, so &lt;code dir=&quot;auto&quot;&gt;Speed&lt;/code&gt; maps to walking speed and &lt;code dir=&quot;auto&quot;&gt;Power&lt;/code&gt; to cycling power.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;WorkoutResult&lt;/code&gt;’s energy/distance are &lt;code dir=&quot;auto&quot;&gt;null&lt;/code&gt; on Android read&lt;/strong&gt; — Health Connect stores those as separate records from the exercise session.&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;MenstrualFlow.None&lt;/code&gt; and the &lt;code dir=&quot;auto&quot;&gt;IsCycleStart&lt;/code&gt; flag are iOS-only.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;See &lt;a href=&quot;https://www.shinylib.net/health/platform-notes&quot;&gt;Platform Notes&lt;/a&gt; for the full list.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;get-started&quot;&gt;Get Started&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.Health&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.Health.Extensions.AI&lt;/span&gt;&lt;span&gt;   &lt;/span&gt;&lt;span&gt;# optional — for LLM agents&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Then head to &lt;a href=&quot;https://www.shinylib.net/health/reading&quot;&gt;Reading Data&lt;/a&gt;, &lt;a href=&quot;https://www.shinylib.net/health/writing&quot;&gt;Writing Data&lt;/a&gt;, and &lt;a href=&quot;https://www.shinylib.net/health/ai-tools&quot;&gt;AI Tools&lt;/a&gt;. One interface, both platforms, 30+ metrics, and now an agent that can read and write them for you.&lt;/p&gt;</content:encoded><category>health</category><category>release</category><category>AI</category></item><item><title>Orleans on DocumentDb — Query Your Grains Without Waking Them Up</title><link>https://www.shinylib.net/blog/2026/06/documentdb-orleans/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/06/documentdb-orleans/</guid><description>Shiny.DocumentDb.Orleans puts the entire Orleans persistence stack — grain storage, reminders, clustering, and grain directory — on one backend-agnostic IDocumentStore. The headline win: query grain state directly, without activating a single grain.</description><pubDate>Sat, 13 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Microsoft Orleans is fantastic at the runtime — virtual actors, automatic placement, transparent activation. But the &lt;em&gt;persistence&lt;/em&gt; story has always been a patchwork: a separate provider package per backend, grain state serialized into an opaque blob you can’t query, and no first-party option at all for some databases (looking at you, MongoDB).&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Orleans&lt;/code&gt; replaces that patchwork with a single idea: build the &lt;strong&gt;whole&lt;/strong&gt; Orleans persistence stack on top of Shiny.DocumentDb’s backend-agnostic &lt;code dir=&quot;auto&quot;&gt;IDocumentStore&lt;/code&gt;. One set of implementations runs on every DocumentDb backend, grain state is persisted as &lt;strong&gt;structured, queryable JSON&lt;/strong&gt;, and you get capabilities Orleans’ built-in providers simply can’t offer.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.DocumentDb.Orleans&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;the-headline-win-query-grain-state-without-activating-grains&quot;&gt;The headline win: query grain state without activating grains&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Orleans grain storage is a point key/value contract — &lt;code dir=&quot;auto&quot;&gt;Read&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Write&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;Clear&lt;/code&gt; by grain id, with &lt;strong&gt;no query surface&lt;/strong&gt;. Want to know &lt;em&gt;“which shopping carts have a total over $1000?”&lt;/em&gt; Normally you can’t ask the store that question at all. You’d have to &lt;em&gt;activate&lt;/em&gt; each grain — a silo round-trip that places the grain, deserializes its state, and runs &lt;code dir=&quot;auto&quot;&gt;OnActivateAsync&lt;/code&gt; — and the first-party providers store state as an opaque serialized blob, so querying the database directly is off the table too.&lt;/p&gt;
&lt;p&gt;Because this provider stores each grain’s state as &lt;strong&gt;structured JSON&lt;/strong&gt; in an ordinary table (under &lt;code dir=&quot;auto&quot;&gt;$.state&lt;/code&gt;), you can run the normal document query API straight against the grain-state table — &lt;strong&gt;no grains activated, no silo involved&lt;/strong&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// A read-only store pointed at the same database + grain-state table.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;opts&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;DocumentStoreOptions&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;DatabaseProvider      &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PostgreSqlDatabaseProvider&lt;/span&gt;&lt;span&gt;(connectionString),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;JsonSerializerOptions &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;JsonSerializerOptions&lt;/span&gt;&lt;span&gt; { PropertyNamingPolicy &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; JsonNamingPolicy.CamelCase }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;};&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;DocumentDbGrainStorage.&lt;/span&gt;&lt;span&gt;ConfigureGrainState&lt;/span&gt;&lt;span&gt;(opts, &lt;/span&gt;&lt;span&gt;&quot;orleans_default&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;readStore&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;DocumentStore&lt;/span&gt;&lt;span&gt;(opts);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Every ShoppingCart grain whose persisted total exceeds 1000 — without activating one.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;bigCarts&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; readStore.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;GrainStateRecord&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&quot;json_extract(Data, &apos;$.state.total&apos;) &gt; @min&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;parameters&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; { min &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;1000&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;This unlocks reporting, dashboards, admin/ops tooling, analytics, and bulk inspection — all the things that are painful-to-impossible when your only door into grain state is activating the grain.&lt;/p&gt;
&lt;p&gt;One honest caveat: these queries see the &lt;strong&gt;last persisted&lt;/strong&gt; state. An activated grain may hold newer in-memory state it hasn’t flushed yet, and the queries take no grain locks. Treat it as an &lt;strong&gt;eventually-consistent read model&lt;/strong&gt; — perfect for reporting, not a substitute for calling the grain when you need authoritative live state.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;a-free-audit-trail-for-every-grain&quot;&gt;A free audit trail for every grain&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Grain state is a &lt;code dir=&quot;auto&quot;&gt;GrainStateRecord&lt;/code&gt; document like any other, so it can opt into DocumentDb’s &lt;a href=&quot;https://www.shinylib.net/documentdb/temporal/&quot;&gt;temporal history&lt;/a&gt;. One line gives you a full, queryable audit trail of every mutation — who changed what, and when:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;opts.&lt;/span&gt;&lt;span&gt;MapTemporal&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;GrainStateRecord&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; t.MaxVersions &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;100&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// later:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;history&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; temporalStore.&lt;/span&gt;&lt;span&gt;History&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;GrainStateRecord&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;&quot;cart|user-42&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;No event sourcing to design, no extra infrastructure to stand up. The version history rides along on the same store.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-whole-stack-not-just-grain-storage&quot;&gt;The whole stack, not just grain storage&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Grain storage is the marquee feature, but it’s only a quarter of the package. The same &lt;code dir=&quot;auto&quot;&gt;IDocumentStore&lt;/code&gt; foundation backs the &lt;strong&gt;entire&lt;/strong&gt; Orleans persistence stack, each with its own silo-builder extension:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;siloBuilder&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddDocumentDbGrainStorage&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Default&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.DatabaseProvider &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PostgreSqlDatabaseProvider&lt;/span&gt;&lt;span&gt;(cs))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddDocumentDbReminders&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.DatabaseProvider &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PostgreSqlDatabaseProvider&lt;/span&gt;&lt;span&gt;(cs))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddDocumentDbClustering&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt;     &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.DatabaseProvider &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PostgreSqlDatabaseProvider&lt;/span&gt;&lt;span&gt;(cs))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddDocumentDbGrainDirectory&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Default&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.DatabaseProvider &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PostgreSqlDatabaseProvider&lt;/span&gt;&lt;span&gt;(cs));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Reminders&lt;/strong&gt; (&lt;code dir=&quot;auto&quot;&gt;IReminderTable&lt;/code&gt;) — each reminder is a queryable document; the hash-ring range reads Orleans needs become a fluent query on the stored &lt;code dir=&quot;auto&quot;&gt;GrainHash&lt;/code&gt;. No multi-document transaction required, so it runs on &lt;strong&gt;any&lt;/strong&gt; backend.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Clustering&lt;/strong&gt; (&lt;code dir=&quot;auto&quot;&gt;IMembershipTable&lt;/code&gt;) — per-silo rows and the global table-version row are updated together inside a &lt;code dir=&quot;auto&quot;&gt;RunInTransaction&lt;/code&gt;, each gated on its own version, honoring Orleans’ table-version protocol.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Grain directory&lt;/strong&gt; (&lt;code dir=&quot;auto&quot;&gt;IGrainDirectory&lt;/code&gt;) — a distributed activation registry with per-row version CAS for register/unregister races; again, no cross-document transaction needed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Instead of stitching together separate provider packages with different conventions, it’s one storage abstraction for the lot.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;backend-agnostic--and-it-fills-a-real-gap&quot;&gt;Backend-agnostic — and it fills a real gap&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Because the runtime binds only to &lt;code dir=&quot;auto&quot;&gt;IDocumentStore&lt;/code&gt;, the same code path serves every backend. Relational providers are built in; MongoDB and Cosmos get first-class companion packages:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;Shiny.DocumentDb.Orleans.MongoDb&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;siloBuilder.&lt;/span&gt;&lt;span&gt;AddMongoDbGrainStorage&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Default&quot;&lt;/span&gt;&lt;span&gt;, connectionString, &lt;/span&gt;&lt;span&gt;databaseName&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;orleans&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Shiny.DocumentDb.Orleans.CosmosDb&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;siloBuilder.&lt;/span&gt;&lt;span&gt;AddCosmosDbGrainStorage&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Default&quot;&lt;/span&gt;&lt;span&gt;, connectionString, &lt;/span&gt;&lt;span&gt;databaseName&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;orleans&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;There is &lt;strong&gt;no first-party Orleans MongoDB provider&lt;/strong&gt; — so this genuinely fills a gap. And switching from PostgreSQL to SQL Server to MongoDB doesn’t mean rewriting your persistence layer; it means swapping a provider line.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;concurrency-thats-actually-correct&quot;&gt;Concurrency that’s actually correct&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Orleans’ ETag is the contract that keeps two activations from clobbering each other during a failover window. This provider maps the ETag to the document version and honors it with each backend’s &lt;strong&gt;atomic&lt;/strong&gt; compare-and-swap:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Orleans&lt;/th&gt;
&lt;th&gt;Shiny.DocumentDb&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;document key&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Id = &quot;{stateName}|{grainId}&quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ETag&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;GrainStateRecord.Version&lt;/code&gt; (via &lt;code dir=&quot;auto&quot;&gt;MapVersionProperty&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;concurrency conflict&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;ConcurrencyException&lt;/code&gt; → &lt;code dir=&quot;auto&quot;&gt;InconsistentStateException&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;state blob&lt;/td&gt;
&lt;td&gt;nested &lt;code dir=&quot;auto&quot;&gt;JsonElement&lt;/code&gt; (stays queryable, not opaque)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The relational providers fold the version check into &lt;code dir=&quot;auto&quot;&gt;UPDATE … WHERE&lt;/code&gt; and verify the row count, MongoDB uses an atomic version-predicate filter, and Cosmos uses a native &lt;code dir=&quot;auto&quot;&gt;IfMatchEtag&lt;/code&gt;. A stale write loses the race and surfaces as an &lt;code dir=&quot;auto&quot;&gt;InconsistentStateException&lt;/code&gt; — exactly what Orleans expects, even during a duplicate-activation window. The PostgreSQL and MongoDB paths (including the stale-write CAS conflict) are covered by automated integration tests.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;reflection-free-serialization-when-you-want-it&quot;&gt;Reflection-free serialization when you want it&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The provider’s own envelope types — grain-state record, reminders, membership, grain-directory rows — are &lt;strong&gt;always&lt;/strong&gt; source-generated. The one generic piece is &lt;em&gt;your&lt;/em&gt; grain state &lt;code dir=&quot;auto&quot;&gt;T&lt;/code&gt;. Point a &lt;code dir=&quot;auto&quot;&gt;JsonSerializerContext&lt;/code&gt; at it and grain-state serialization goes reflection-free too:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;JsonSerializable&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;typeof&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;CartState&lt;/span&gt;&lt;span&gt;))]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;JsonSerializable&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;typeof&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;UserPrefs&lt;/span&gt;&lt;span&gt;))]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;partial&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;GrainStateContext&lt;/span&gt;&lt;span&gt; : &lt;/span&gt;&lt;span&gt;JsonSerializerContext&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;siloBuilder.&lt;/span&gt;&lt;span&gt;AddDocumentDbGrainStorage&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Default&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.DatabaseProvider      &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PostgreSqlDatabaseProvider&lt;/span&gt;&lt;span&gt;(cs);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.JsonSerializerOptions &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;JsonSerializerOptions&lt;/span&gt;&lt;span&gt; { TypeInfoResolver &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; GrainStateContext.Default };&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;o.UseReflectionFallback &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;false&lt;/span&gt;&lt;span&gt;;   &lt;/span&gt;&lt;span&gt;// throw on an unregistered state type instead of reflecting&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;It’s purely opt-in — leave the defaults and you keep the familiar reflection-based behavior.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;know-your-backend&quot;&gt;Know your backend&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Not every database is equal for every job. The compatibility tiers are worth a glance before you go to production:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Backends&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Recommended&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PostgreSQL, SQL Server, MySQL, Oracle&lt;/td&gt;
&lt;td&gt;Atomic CAS folded into &lt;code dir=&quot;auto&quot;&gt;UPDATE … WHERE&lt;/code&gt;; ETag honored across failover windows.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Supported&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MongoDB&lt;/td&gt;
&lt;td&gt;Good key distribution; atomic CAS via version-predicate filter.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Limited / dev&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SQLite, LiteDB, IndexedDB, DuckDB&lt;/td&gt;
&lt;td&gt;Single-writer / embedded / analytical — fine for dev, single-silo, or edge.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Use with care&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cosmos DB&lt;/td&gt;
&lt;td&gt;CAS is correct, but it partitions by grain type — weigh the 20 GB / hot-partition tradeoff before large-scale use.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two limits to keep in mind: &lt;strong&gt;membership/clustering needs real multi-document transactions&lt;/strong&gt;, so it runs on the relational providers or a MongoDB replica set — not Cosmos (grain storage, reminders, and grain directory have no such requirement). And the &lt;strong&gt;silo host itself isn’t an AOT target&lt;/strong&gt; — serialization can be reflection-free, but &lt;code dir=&quot;auto&quot;&gt;Microsoft.Orleans.Runtime&lt;/code&gt; is codegen-heavy, so a fully AOT-published silo isn’t a goal here.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;get-started&quot;&gt;Get started&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.DocumentDb.Orleans&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# optional companions&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.DocumentDb.Orleans.MongoDb&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.DocumentDb.Orleans.CosmosDb&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Full setup, the options reference, and the query-without-activation walkthrough are in the &lt;a href=&quot;https://www.shinylib.net/documentdb/orleans/&quot;&gt;Orleans Provider docs&lt;/a&gt;. If you’re already on Shiny.DocumentDb, your grains are one &lt;code dir=&quot;auto&quot;&gt;siloBuilder&lt;/code&gt; call away from a store you can actually query.&lt;/p&gt;</content:encoded><category>documentdb</category><category>orleans</category><category>release</category></item><item><title>Shiny.Data.Sync — Offline-First Record Sync, Built on Jobs &amp; HTTP Transfers</title><link>https://www.shinylib.net/blog/2026/06/datasync/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/06/datasync/</guid><description>Shiny.Data.Sync is a background-capable, bidirectional record-sync engine. It doesn&apos;t reinvent background execution — it stands on the same playbook as Shiny Jobs and Shiny.Net.Http, moving records where transfers move files and riding the Jobs scheduler for periodic pulls.</description><pubDate>Sat, 13 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Shiny already had two answers for “do work when the app isn’t in the foreground”:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/jobs/&quot;&gt;Shiny Jobs&lt;/a&gt;&lt;/strong&gt; runs &lt;em&gt;periodic background tasks&lt;/em&gt; — WorkManager on Android, BGTaskScheduler on iOS, an in-process timer everywhere else.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/httptransfers/&quot;&gt;Shiny.Net.Http&lt;/a&gt;&lt;/strong&gt; moves &lt;em&gt;files&lt;/em&gt; in the background — &lt;code dir=&quot;auto&quot;&gt;NSURLSession&lt;/code&gt; on iOS, a foreground service on Android, a connectivity-driven &lt;code dir=&quot;auto&quot;&gt;HttpClient&lt;/code&gt; loop elsewhere.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There was a gap in the middle: &lt;strong&gt;records&lt;/strong&gt;. Not a 40 MB video, not a timer that fires every hour — the dozen &lt;code dir=&quot;auto&quot;&gt;Create&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;Update&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;Delete&lt;/code&gt; operations a user generates offline that need to reach a REST API reliably, survive an app kill, and come back with the server’s changes.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.Data.Sync&lt;/code&gt; fills that gap. The important part of this post isn’t “here’s a new library” — it’s that &lt;strong&gt;Data Sync deliberately doesn’t reinvent background execution&lt;/strong&gt;. It rides the exact same OS playbook Jobs and HTTP Transfers already proved out.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;three-libraries-one-background-playbook&quot;&gt;Three libraries, one background playbook&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;iOS and Android take cross-platform “background” promises away from you. Shiny’s answer has always been to match what each OS actually allows rather than pretend a single mechanism works everywhere. All three libraries land on the same per-platform tiers:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Jobs&lt;/th&gt;
&lt;th&gt;HTTP Transfers&lt;/th&gt;
&lt;th&gt;Data Sync&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;iOS / Mac Catalyst&lt;/td&gt;
&lt;td&gt;BGTaskScheduler&lt;/td&gt;
&lt;td&gt;Background &lt;code dir=&quot;auto&quot;&gt;NSURLSession&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Background &lt;code dir=&quot;auto&quot;&gt;NSURLSession&lt;/code&gt; (upload + download tasks)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;WorkManager&lt;/td&gt;
&lt;td&gt;Foreground service + &lt;code dir=&quot;auto&quot;&gt;HttpClient&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Foreground service + &lt;code dir=&quot;auto&quot;&gt;HttpClient&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Windows / Linux / macOS&lt;/td&gt;
&lt;td&gt;In-process timer&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;HttpClient&lt;/code&gt; + connectivity loop&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;HttpClient&lt;/code&gt; + connectivity loop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blazor WASM&lt;/td&gt;
&lt;td&gt;In-process (tab alive)&lt;/td&gt;
&lt;td&gt;Service Worker Background Sync&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;HttpClient&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;LocalStorage&lt;/code&gt; (tab alive)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;If you’ve shipped a background download with &lt;code dir=&quot;auto&quot;&gt;Shiny.Net.Http&lt;/code&gt;, you already understand Data Sync’s runtime model — because it’s the same model. The library description says it outright: where transfers move files, sync moves records, and the two &lt;strong&gt;deliberately share their playbook, because the OS guarantees are the same&lt;/strong&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;how-data-sync-uses-jobs&quot;&gt;How Data Sync &lt;em&gt;uses&lt;/em&gt; Jobs&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;You don’t wire up a background pull yourself. &lt;code dir=&quot;auto&quot;&gt;AddDataSync&amp;#x3C;TDelegate&gt;&lt;/code&gt; registers a &lt;code dir=&quot;auto&quot;&gt;SyncJob&lt;/code&gt; with the Shiny Jobs scheduler for you:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// This is effectively what AddDataSync does under the hood — no AddJob call required:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;services.&lt;/span&gt;&lt;span&gt;AddJob&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;SyncJob&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;r&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; r.&lt;/span&gt;&lt;span&gt;WithInternet&lt;/span&gt;&lt;span&gt;(InternetAccess.Any));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That means periodic inbox pulls keep happening on whatever background cadence the OS allows — WorkManager on Android, BGTaskScheduler on iOS — using the same &lt;code dir=&quot;auto&quot;&gt;IJobManager&lt;/code&gt; you’d use for any other Shiny job. The job respects each endpoint’s &lt;code dir=&quot;auto&quot;&gt;MinPullInterval&lt;/code&gt; so it doesn’t hammer your server, and because it’s a normal job you can turn it off through the normal job API when your pulls are push-triggered instead:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;jobs&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; host.Services.&lt;/span&gt;&lt;span&gt;GetRequiredService&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;IJobManager&lt;/span&gt;&lt;span&gt;&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; jobs.&lt;/span&gt;&lt;span&gt;Cancel&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;nameof&lt;/span&gt;&lt;span&gt;(Shiny.Data.Sync.SyncJob));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;This is the win of building &lt;em&gt;on&lt;/em&gt; Jobs rather than beside it: the scheduler, the runtime criteria (&lt;code dir=&quot;auto&quot;&gt;WithInternet&lt;/code&gt;, charging, battery), and the platform background hooks are already solved. Data Sync just registers a job and inherits all of it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;how-data-sync-mirrors-http-transfers&quot;&gt;How Data Sync mirrors HTTP Transfers&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The architectural heart of &lt;code dir=&quot;auto&quot;&gt;Shiny.Net.Http&lt;/code&gt; is a &lt;strong&gt;persistent queue drained by a platform-tiered transport&lt;/strong&gt;. A transfer is written to disk before any network call, so a process kill mid-transfer leaves the work intact and the next launch (or the OS itself, on iOS) resumes it.&lt;/p&gt;
&lt;p&gt;Data Sync uses the identical pattern for its &lt;strong&gt;outbox&lt;/strong&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TodosService&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;IDataSyncManager&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sync&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Create&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;TodoItem&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;item&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; sync.&lt;/span&gt;&lt;span&gt;Queue&lt;/span&gt;&lt;span&gt;(SyncVerb.Create, item);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Update&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;TodoItem&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;item&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; sync.&lt;/span&gt;&lt;span&gt;Queue&lt;/span&gt;&lt;span&gt;(SyncVerb.Update, item);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Delete&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;TodoItem&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;item&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; sync.&lt;/span&gt;&lt;span&gt;Queue&lt;/span&gt;&lt;span&gt;(SyncVerb.Delete, item);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Queue&amp;#x3C;T&gt;&lt;/code&gt; writes a durable &lt;code dir=&quot;auto&quot;&gt;SyncOperation&lt;/code&gt; to the Shiny repository &lt;strong&gt;before&lt;/strong&gt; touching the network, then returns immediately — the caller never blocks on the round-trip. From there it’s pure HTTP Transfers thinking:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;On &lt;strong&gt;iOS / Mac Catalyst&lt;/strong&gt;, queued ops drive &lt;code dir=&quot;auto&quot;&gt;NSURLSession&lt;/code&gt; upload tasks. Even if Shiny’s in-process queue dies, the OS keeps its own queue and drives the upload to completion, waking the app to dispatch the result — exactly how background file uploads survive suspension.&lt;/li&gt;
&lt;li&gt;On &lt;strong&gt;Android&lt;/strong&gt;, ops drain inside a foreground service that spawns on &lt;code dir=&quot;auto&quot;&gt;Queue&amp;#x3C;T&gt;&lt;/code&gt; and dies when the queue empties — the same foreground-service contract transfers use to stay alive while work is pending.&lt;/li&gt;
&lt;li&gt;On &lt;strong&gt;Windows / Linux / macOS / Blazor&lt;/strong&gt;, an in-process &lt;code dir=&quot;auto&quot;&gt;HttpClient&lt;/code&gt; loop drains the queue, woken by &lt;code dir=&quot;auto&quot;&gt;IConnectivity.Changed&lt;/code&gt;, app startup, and &lt;code dir=&quot;auto&quot;&gt;Queue&amp;#x3C;T&gt;&lt;/code&gt; itself — the same connectivity loop that drives transfers off-Apple.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Attempts&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;NextAttemptAt&lt;/code&gt; are persisted alongside each op, so even the exponential-backoff window survives a restart. None of that is new machinery — it’s the transfers playbook applied to records.&lt;/p&gt;
&lt;p&gt;Where it goes &lt;em&gt;beyond&lt;/em&gt; a file transfer is the second direction: an &lt;strong&gt;inbox&lt;/strong&gt; that pulls server deltas keyed by an opaque cursor, draining pages until the server says &lt;code dir=&quot;auto&quot;&gt;hasMore: false&lt;/code&gt;. A file transfer is one-way; a record sync is two-way, so Data Sync adds the inbox, tombstone streams, conflict resolution, and an operation coalescer on top of the shared foundation.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;setup-end-to-end&quot;&gt;Setup, end to end&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// 1. Entity — one property&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;record&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TodoItem&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Identifier&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Title&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;bool&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Completed&lt;/span&gt;&lt;span&gt;) : &lt;/span&gt;&lt;span&gt;ISyncEntity&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// 2. AOT-safe JSON, once per app&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;ShinyJsonContext&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;JsonSerializable&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;typeof&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;TodoItem&lt;/span&gt;&lt;span&gt;))]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;partial&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AppJsonContext&lt;/span&gt;&lt;span&gt; : &lt;/span&gt;&lt;span&gt;JsonSerializerContext&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// 3. Register — picks the transport for the TFM AND auto-registers SyncJob&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddDataSync&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;MyDataSyncDelegate&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;opts&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;opts.&lt;/span&gt;&lt;span&gt;RegisterEndpoint&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;TodoItem&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;&quot;https://api.example.com/todos&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;ep&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;ep.Direction &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; SyncDirection.Both;            &lt;/span&gt;&lt;span&gt;// PullOnly / PushOnly also valid&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;ep.Batch &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;;                              &lt;/span&gt;&lt;span&gt;// coalesce redundant ops per round-trip&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;ep.MinPullInterval &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; TimeSpan.&lt;/span&gt;&lt;span&gt;FromMinutes&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;5&lt;/span&gt;&lt;span&gt;); &lt;/span&gt;&lt;span&gt;// throttle the scheduled SyncJob&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;ep.MaxAttempts &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;8&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;ep.DefaultConflictPolicy &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; ConflictPolicy.ServerWins;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Your one &lt;code dir=&quot;auto&quot;&gt;IDataSyncDelegate&lt;/code&gt; is the integration seam — &lt;code dir=&quot;auto&quot;&gt;OnSent&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;OnError&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;OnReceived&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;OnConflict&lt;/code&gt;. Received items arrive already deserialized and strongly typed; you apply them to whatever local store you like (Data Sync is a transport, not a database — pair it with &lt;a href=&quot;https://www.shinylib.net/documentdb/&quot;&gt;DocumentDB&lt;/a&gt; inside &lt;code dir=&quot;auto&quot;&gt;OnReceived&lt;/code&gt; if you want local query).&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;so-which-one-do-i-reach-for&quot;&gt;So which one do I reach for?&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This is the question the three libraries answer together:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You need to…&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Run a periodic background task (cleanup, refresh, telemetry flush)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/jobs/&quot;&gt;Jobs&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A scheduler with runtime criteria. No queue, no HTTP shape.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Move a large file up or down, resumable, in the background&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/httptransfers/&quot;&gt;HTTP Transfers&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Range-aware resume for multi-megabyte blobs. One-way.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reliably push record CRUD and pull deltas, offline-first&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;a href=&quot;https://www.shinylib.net/datasync/&quot;&gt;Data Sync&lt;/a&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Persistent outbox + cursor inbox, drain-on-reconnect, conflict handling.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;They compose rather than compete. A real offline-first app often uses all three: &lt;strong&gt;Jobs&lt;/strong&gt; for the periodic housekeeping, &lt;strong&gt;HTTP Transfers&lt;/strong&gt; for the user’s photo attachments, and &lt;strong&gt;Data Sync&lt;/strong&gt; for the records those photos belong to — every one of them riding the same &lt;code dir=&quot;auto&quot;&gt;NSURLSession&lt;/code&gt; / foreground-service / connectivity-loop tiering under the hood.&lt;/p&gt;
&lt;p&gt;And the boundary is explicit. Data Sync’s own docs tell you when to step &lt;em&gt;out&lt;/em&gt; of it: large blobs go to HTTP Transfers, realtime streams go to SignalR or Push, and a client-of-record backup is a file push, not a sync. Moving records — &lt;code dir=&quot;auto&quot;&gt;Create&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;Update&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;Delete&lt;/code&gt; queued on failure, drained on reconnect, pulled back as deltas — is the lane it’s built for.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;get-started&quot;&gt;Get started&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.Data.Sync&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.shinylib.net/datasync/&quot;&gt;Data Sync — Getting Started&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.shinylib.net/datasync/architecture&quot;&gt;Architecture&lt;/a&gt; — why outbox + inbox, why platform-tiered transports&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.shinylib.net/datasync/platform-behavior&quot;&gt;Platform Behavior&lt;/a&gt; — what survives an app kill, and the Info.plist / manifest bits&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.shinylib.net/jobs/&quot;&gt;Jobs&lt;/a&gt; and &lt;a href=&quot;https://www.shinylib.net/httptransfers/&quot;&gt;HTTP Transfers&lt;/a&gt; — the two libraries it builds on&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you already know how Shiny runs work in the background, you already know how Data Sync runs. It just moves records.&lt;/p&gt;</content:encoded><category>datasync</category><category>jobs</category><category>httptransfers</category><category>release</category></item><item><title>Shiny.DocumentDb v6 — Vectors, Filters, Composite Indexes &amp; Real Pooling</title><link>https://www.shinylib.net/blog/2026/06/documentdb-v6/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/06/documentdb-v6/</guid><description>v6 adds cross-provider vector / ANN search, EF-style global query filters, composite JSON indexes, multi-connection server pooling, and per-query change monitoring — all without changing the schema-free, AOT-safe shape of the API.</description><pubDate>Mon, 01 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;a href=&quot;https://www.nuget.org/packages/Shiny.DocumentDb&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.DocumentDb?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.DocumentDb&quot; alt=&quot;NuGet package Shiny.DocumentDb&quot;&gt;&lt;/a&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb&lt;/code&gt; v6 is out.  Same one-line &lt;code dir=&quot;auto&quot;&gt;services.AddDocumentStore(...)&lt;/code&gt;, same zero-schema document model, same AOT story — but the v6 release lands five features that have been on the wish list since v3:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Vector / ANN search&lt;/strong&gt; that translates to the native engine on every provider (pgvector, SQL Server 2025 &lt;code dir=&quot;auto&quot;&gt;VECTOR_DISTANCE&lt;/code&gt;, Cosmos DiskANN, Mongo Atlas &lt;code dir=&quot;auto&quot;&gt;$vectorSearch&lt;/code&gt;, DuckDB &lt;code dir=&quot;auto&quot;&gt;vss&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;sqlite-vec&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Global query filters&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;AddQueryFilter&amp;#x3C;T&gt;(u =&gt; !u.IsDeleted)&lt;/code&gt; and they apply to &lt;em&gt;every&lt;/em&gt; read, single-doc fetch, bulk operation, and change-stream subscription. Same shape as EF Core’s &lt;code dir=&quot;auto&quot;&gt;HasQueryFilter&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Composite (multi-column) JSON indexes&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;CreateIndexAsync&amp;#x3C;T&gt;(ctx.User, u =&gt; u.LastName, u =&gt; u.FirstName)&lt;/code&gt; on every relational provider.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Real connection pooling on server SQL&lt;/strong&gt; — PostgreSQL, MySQL, and SQL Server stop serializing through a per-store semaphore and start using the ADO.NET driver’s pool. One &lt;code dir=&quot;auto&quot;&gt;DocumentStore&lt;/code&gt; instance can now actually serve a web app.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Per-query change monitoring&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;.NotifyOnChange()&lt;/code&gt; on any fluent query, filtered by the query’s &lt;code dir=&quot;auto&quot;&gt;Where&lt;/code&gt; predicates.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The full changelog is on the &lt;a href=&quot;https://www.shinylib.net/documentdb/release-notes/&quot;&gt;release notes&lt;/a&gt; page. This post walks through the headliners.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;vector--ann-search&quot;&gt;Vector / ANN Search&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Embedding-search has been the most requested feature since the AI tools shipped in v4. v6 closes that gap.&lt;/p&gt;
&lt;p&gt;Register an embedding property and query by similarity:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Guid&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Id&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;set&lt;/span&gt;&lt;span&gt;; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Content&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;set&lt;/span&gt;&lt;span&gt;; } &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ReadOnlyMemory&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;float&lt;/span&gt;&lt;span&gt;&gt; &lt;/span&gt;&lt;span&gt;Embedding&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;set&lt;/span&gt;&lt;span&gt;; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;DocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;DocumentStoreOptions&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;DatabaseProvider &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;SqliteDatabaseProvider&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Data Source=mydata.db&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;EnableVectorExtension &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;   &lt;/span&gt;&lt;span&gt;// loads sqlite-vec on every connection&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}.&lt;/span&gt;&lt;span&gt;MapVectorProperty&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;d&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; d.Embedding,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;dimensions&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;1536&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;metric&lt;/span&gt;&lt;span&gt;: VectorDistance.Cosine,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;indexKind&lt;/span&gt;&lt;span&gt;: VectorIndexKind.Hnsw));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;hits&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;span&gt;&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Where&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;d&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; d.Content.&lt;/span&gt;&lt;span&gt;Contains&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;invoice&quot;&lt;/span&gt;&lt;span&gt;))   &lt;/span&gt;&lt;span&gt;// pre-filter where supported&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;NearestVectors&lt;/span&gt;&lt;span&gt;(queryEmbedding, &lt;/span&gt;&lt;span&gt;k&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;10&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;foreach&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;hit&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; hits)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Console.&lt;/span&gt;&lt;span&gt;WriteLine&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;$&quot;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;hit&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Score&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt;F4&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;hit&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Content&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The vector type is &lt;code dir=&quot;auto&quot;&gt;ReadOnlyMemory&amp;#x3C;float&gt;&lt;/code&gt; everywhere — same shape as &lt;code dir=&quot;auto&quot;&gt;Microsoft.Extensions.AI.Embedding&amp;#x3C;float&gt;.Vector&lt;/code&gt;, JSON-round-trips through &lt;code dir=&quot;auto&quot;&gt;System.Text.Json&lt;/code&gt; without a custom converter, and avoids an &lt;code dir=&quot;auto&quot;&gt;float[]&lt;/code&gt; allocation on every read.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;provider-matrix&quot;&gt;Provider matrix&lt;/h3&gt;&lt;/div&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Storage&lt;/th&gt;
&lt;th&gt;Index&lt;/th&gt;
&lt;th&gt;Filter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PostgreSQL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;pgvector&lt;/code&gt; sidecar&lt;/td&gt;
&lt;td&gt;HNSW, IVF&lt;/td&gt;
&lt;td&gt;Pre-filter via JOIN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQL Server 2025&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Native &lt;code dir=&quot;auto&quot;&gt;VECTOR(n)&lt;/code&gt; sidecar&lt;/td&gt;
&lt;td&gt;DiskANN&lt;/td&gt;
&lt;td&gt;Pre-filter via JOIN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cosmos DB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Embedded in document JSON&lt;/td&gt;
&lt;td&gt;DiskANN, QuantizedFlat, Flat&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;WHERE&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;ORDER BY VectorDistance(...)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MongoDB (Atlas)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;$vectorSearch&lt;/code&gt; aggregation&lt;/td&gt;
&lt;td&gt;HNSW (Atlas-managed)&lt;/td&gt;
&lt;td&gt;Filter inside &lt;code dir=&quot;auto&quot;&gt;$vectorSearch&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DuckDB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;vss&lt;/code&gt; sidecar&lt;/td&gt;
&lt;td&gt;HNSW&lt;/td&gt;
&lt;td&gt;Pre-filter via JOIN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQLite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;sqlite-vec&lt;/code&gt; virtual table&lt;/td&gt;
&lt;td&gt;None (flat scan)&lt;/td&gt;
&lt;td&gt;Post-filter join back&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MySQL / LiteDB / IndexedDB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Throws &lt;code dir=&quot;auto&quot;&gt;NotSupportedException&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Cosine&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;Euclidean&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;DotProduct&lt;/code&gt; are available everywhere; &lt;code dir=&quot;auto&quot;&gt;Hamming&lt;/code&gt; is pgvector-only. Cosine distance is always surfaced as &lt;code dir=&quot;auto&quot;&gt;[0, 2]&lt;/code&gt; regardless of which way the underlying engine likes to count, so &lt;code dir=&quot;auto&quot;&gt;ORDER BY score ASC&lt;/code&gt; works the same way on every provider.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;auto-embed-on-insert&quot;&gt;Auto-embed on insert&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;If you don’t want to call &lt;code dir=&quot;auto&quot;&gt;IEmbeddingGenerator&lt;/code&gt; by hand on every write, &lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Extensions.AI&lt;/code&gt; ships an &lt;code dir=&quot;auto&quot;&gt;AutoEmbedOnInsert&amp;#x3C;T&gt;&lt;/code&gt; helper that hooks the new &lt;code dir=&quot;auto&quot;&gt;OnBeforeInsert&amp;#x3C;T&gt;&lt;/code&gt; pipeline:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;using&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;DocumentDb&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Extensions&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AI&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;opts.&lt;/span&gt;&lt;span&gt;MapVectorProperty&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;d&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; d.Embedding, &lt;/span&gt;&lt;span&gt;dimensions&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;1536&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AutoEmbedOnInsert&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;embeddingGenerator,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;sourceSelector&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;d&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; d.Content,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;targetSetter&lt;/span&gt;&lt;span&gt;: (&lt;/span&gt;&lt;span&gt;d&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;vec&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; d.Embedding &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; vec,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;targetGetter&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;d&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; d.Embedding);   &lt;/span&gt;&lt;span&gt;// skip when already set&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Insert&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Document&lt;/span&gt;&lt;span&gt; { Content &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;hello world&quot;&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Embedding is populated automatically before the row hits the wire.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;It runs on &lt;code dir=&quot;auto&quot;&gt;Insert&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;BatchInsert&lt;/code&gt;, and &lt;code dir=&quot;auto&quot;&gt;Upsert&lt;/code&gt;, skips when the source is null/empty, and skips when the target already holds a non-default vector so explicit writes always win.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;tuning-knobs&quot;&gt;Tuning knobs&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;VectorIndexOptions&lt;/code&gt; gives you strongly-typed HNSW (&lt;code dir=&quot;auto&quot;&gt;M&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;EfConstruction&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;EfSearch&lt;/code&gt;) and IVF (&lt;code dir=&quot;auto&quot;&gt;Lists&lt;/code&gt;) settings plus a &lt;code dir=&quot;auto&quot;&gt;ProviderHints&lt;/code&gt; dictionary for the long tail (&lt;code dir=&quot;auto&quot;&gt;sqlite.postFilterMultiplier&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;atlas.indexName&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;atlas.numCandidates&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;Full design notes are in the &lt;a href=&quot;https://www.shinylib.net/documentdb/vector/&quot;&gt;Vector docs&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;global-query-filters&quot;&gt;Global Query Filters&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;If you have shipped anything on Entity Framework Core, you have written this:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;modelBuilder.&lt;/span&gt;&lt;span&gt;Entity&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;User&lt;/span&gt;&lt;span&gt;&gt;().&lt;/span&gt;&lt;span&gt;HasQueryFilter&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;u&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;u.IsDeleted);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb&lt;/code&gt; v6 gets the same surface:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;store&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;DocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;DocumentStoreOptions&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;DatabaseProvider &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;SqliteDatabaseProvider&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Data Source=mydata.db&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddQueryFilter&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;User&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;u&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;u.IsDeleted)                         &lt;/span&gt;&lt;span&gt;// unnamed&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddQueryFilter&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;&quot;tenant&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.TenantId &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; ctx.Current) &lt;/span&gt;&lt;span&gt;// named&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddQueryFilter&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;&quot;status&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.Status &lt;/span&gt;&lt;span&gt;!=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;Archived&quot;&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Filters AND together; the user’s &lt;code dir=&quot;auto&quot;&gt;Where&lt;/code&gt; is AND’d on top. Captured variables (&lt;code dir=&quot;auto&quot;&gt;ctx.Current&lt;/code&gt;) are re-read on every translation, so per-request tenant scopes work without rebuilding the store.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;what-gets-filtered&quot;&gt;What gets filtered&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The interesting decision is what &lt;em&gt;isn’t&lt;/em&gt; filtered. v6 follows EF Core: every read path enforces the filter, but inserts and raw SQL stay free.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Filtered?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Query&amp;#x3C;T&gt;()&lt;/code&gt; + every terminal (&lt;code dir=&quot;auto&quot;&gt;ToList&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Count&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;ExecuteUpdate&lt;/code&gt;, …)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;query.NotifyOnChange()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes — only matching documents emit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Get&amp;#x3C;T&gt;(id)&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;GetDiff&amp;#x3C;T&gt;(id, ...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes — returns &lt;code dir=&quot;auto&quot;&gt;null&lt;/code&gt; if filter fails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Update&amp;#x3C;T&gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes — throws “not found” if filter fails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;SetProperty&amp;#x3C;T&gt;&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;RemoveProperty&amp;#x3C;T&gt;&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;Remove&amp;#x3C;T&gt;(id)&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;Clear&amp;#x3C;T&gt;()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Insert&amp;#x3C;T&gt;&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;BatchInsert&amp;#x3C;T&gt;&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;Upsert&amp;#x3C;T&gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt; — matches EF Core&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Query&amp;#x3C;T&gt;(rawSql)&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;QueryStream&amp;#x3C;T&gt;(rawSql)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt; — your SQL, your call&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Per-query opt-out matches EF Core too:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Disable all filters&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;allUsers&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;User&lt;/span&gt;&lt;span&gt;&gt;().&lt;/span&gt;&lt;span&gt;IgnoreQueryFilters&lt;/span&gt;&lt;span&gt;().&lt;/span&gt;&lt;span&gt;ToList&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Disable a specific named filter (others still apply)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;anyTenant&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;().&lt;/span&gt;&lt;span&gt;IgnoreQueryFilters&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;tenant&quot;&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;ToList&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;This works on every provider that has a real query translator: relational SQL (&lt;code dir=&quot;auto&quot;&gt;DocumentStore&lt;/code&gt;), &lt;code dir=&quot;auto&quot;&gt;LiteDbDocumentStore&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;CosmosDbDocumentStore&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MongoDbDocumentStore&lt;/code&gt;, and &lt;code dir=&quot;auto&quot;&gt;IndexedDbDocumentStore&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Full reference: &lt;a href=&quot;https://www.shinylib.net/documentdb/query-filters/&quot;&gt;Global Query Filters&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;composite-json-indexes&quot;&gt;Composite JSON Indexes&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;CreateIndexAsync&amp;#x3C;T&gt;&lt;/code&gt; has accepted a single expression since v3. v6 adds a multi-expression overload:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Single-column (unchanged)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;CreateIndexAsync&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;User&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;u&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; u.Name, ctx.User);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Composite — one B-tree over multiple JSON paths&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;CreateIndexAsync&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;ctx.User,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;u&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; u.LastName,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;u&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; u.FirstName);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The composite index name is built by joining the resolved paths with &lt;code dir=&quot;auto&quot;&gt;__&lt;/code&gt;, so &lt;code dir=&quot;auto&quot;&gt;ix_User_LastName__FirstName&lt;/code&gt; is the resulting object on disk. Drop the composite index with the matching overload:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;DropIndexAsync&lt;/span&gt;&lt;span&gt;(ctx.User, &lt;/span&gt;&lt;span&gt;u&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; u.LastName, &lt;/span&gt;&lt;span&gt;u&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; u.FirstName);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;How each provider implements it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SQLite / SQLCipher / PostgreSQL / MySQL / DuckDB&lt;/strong&gt; — one composite index with one &lt;code dir=&quot;auto&quot;&gt;json_extract&lt;/code&gt; (or provider equivalent) expression per path. Single statement, single index object.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SQL Server&lt;/strong&gt; — JSON expression indexes need &lt;code dir=&quot;auto&quot;&gt;PERSISTED&lt;/code&gt; computed columns. v6 creates one column per path (&lt;code dir=&quot;auto&quot;&gt;cc_{indexName}_0&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;cc_{indexName}_1&lt;/code&gt;, …) and indexes them all. The drop path discovers the backing computed columns from &lt;code dir=&quot;auto&quot;&gt;sys.index_columns&lt;/code&gt;, so single- and multi-column indexes drop through the same code path with no special-case logic.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Existing single-path index names are preserved bit-for-bit, so v5 indexes survive an upgrade without an &lt;code dir=&quot;auto&quot;&gt;OBJECT_DROP_FAILED&lt;/code&gt; somewhere in production.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;real-connection-pooling-on-server-sql&quot;&gt;Real Connection Pooling on Server SQL&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;v5 was honest about its limit: a single &lt;code dir=&quot;auto&quot;&gt;DocumentStore&lt;/code&gt; instance serialized every operation through one semaphore around one long-lived connection. Fine for a phone, miserable for a server.&lt;/p&gt;
&lt;p&gt;v6 splits behaviour along the provider:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PostgreSQL, MySQL, SQL Server&lt;/strong&gt; — open a connection per operation. The ADO.NET driver’s pool multiplexes callers. One store, many concurrent calls, no in-process queueing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SQLite, SQLCipher, DuckDB&lt;/strong&gt; — embedded engines that take a database-wide write lock. These keep the v5 model: one long-lived connection, one per-store semaphore. The provider declares which mode it wants via &lt;code dir=&quot;auto&quot;&gt;IDatabaseProvider.RequiresSingleConnection&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;RunInTransaction&lt;/code&gt; pins one connection for the duration of the user callback regardless of provider, so every nested operation shares the transaction.&lt;/p&gt;
&lt;p&gt;Table init is now backed by a &lt;code dir=&quot;auto&quot;&gt;ConcurrentDictionary&amp;#x3C;string, Lazy&amp;#x3C;Task&gt;&gt;&lt;/code&gt; — first-touch DDL runs exactly once per table even under concurrent first calls. No more “is the schema there yet?” races on cold start.&lt;/p&gt;
&lt;p&gt;A small but important consequence for streaming: on the pooled providers, &lt;code dir=&quot;auto&quot;&gt;await foreach (... in store.Query&amp;#x3C;T&gt;().ToAsyncEnumerable())&lt;/code&gt; holds one connection out of the pool for the lifetime of the iterator instead of holding the whole store. Other callers don’t block. On the embedded engines, behaviour is unchanged — finish the enumeration before issuing another store call.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;per-query-change-monitoring&quot;&gt;Per-Query Change Monitoring&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;IObservableDocumentStore&lt;/code&gt; shipped in v5.3 with a global, type-scoped stream of &lt;code dir=&quot;auto&quot;&gt;DocumentChange&amp;#x3C;T&gt;&lt;/code&gt;. v6 adds a query-scoped overload — every fluent query now exposes a &lt;code dir=&quot;auto&quot;&gt;.NotifyOnChange()&lt;/code&gt; that filters the change feed by the query’s own &lt;code dir=&quot;auto&quot;&gt;Where&lt;/code&gt; predicates:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;pending&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; store.&lt;/span&gt;&lt;span&gt;Query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;().&lt;/span&gt;&lt;span&gt;Where&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;o&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; o.Status &lt;/span&gt;&lt;span&gt;==&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;Pending&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;foreach&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;change&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;in&lt;/span&gt;&lt;span&gt; pending.&lt;/span&gt;&lt;span&gt;NotifyOnChange&lt;/span&gt;&lt;span&gt;(ct))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;// Only fires when an Order matching Status == &quot;Pending&quot; is inserted or updated.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;UpdateUi&lt;/span&gt;&lt;span&gt;(change);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;OrderBy&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Paginate&lt;/code&gt;, and &lt;code dir=&quot;auto&quot;&gt;GroupBy&lt;/code&gt; are ignored because they change result shape, not membership. Calling &lt;code dir=&quot;auto&quot;&gt;Select(...)&lt;/code&gt; first throws — projecting away the document body breaks the filter.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;SetProperty&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;RemoveProperty&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Remove&lt;/code&gt;, and &lt;code dir=&quot;auto&quot;&gt;Clear&lt;/code&gt; don’t carry the full document, so &lt;code dir=&quot;auto&quot;&gt;DocumentChange&amp;#x3C;T&gt;.Document&lt;/code&gt; is &lt;code dir=&quot;auto&quot;&gt;null&lt;/code&gt; for those events. The per-query filter passes them through unconditionally so the consumer can re-query and decide for itself whether the document still matches.&lt;/p&gt;
&lt;p&gt;Combined with the new global query filters and the existing &lt;code dir=&quot;auto&quot;&gt;IChangeFeedDocumentStore&lt;/code&gt; (cross-process change feeds backed by PostgreSQL &lt;code dir=&quot;auto&quot;&gt;LISTEN&lt;/code&gt;/&lt;code dir=&quot;auto&quot;&gt;NOTIFY&lt;/code&gt;, SQL Server Change Tracking, and Cosmos DB Change Feed), change observation is now end-to-end coherent: every read goes through the same filter; every change subscription sees only the changes that match.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;other-notable-v6-items&quot;&gt;Other Notable v6 Items&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A few smaller things that show up in the &lt;a href=&quot;https://www.shinylib.net/documentdb/release-notes/&quot;&gt;release notes&lt;/a&gt; but didn’t get their own section:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;MapIdProperty&amp;#x3C;T&gt;(...)&lt;/code&gt;&lt;/strong&gt; — standalone Id-property override that no longer requires &lt;code dir=&quot;auto&quot;&gt;MapTypeToTable&lt;/code&gt;. Use it when the Id is named &lt;code dir=&quot;auto&quot;&gt;Slug&lt;/code&gt; or &lt;code dir=&quot;auto&quot;&gt;DeviceKey&lt;/code&gt; but you still want the type stored in the default shared table.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;OnBeforeInsert&amp;#x3C;T&gt;&lt;/code&gt;&lt;/strong&gt; — async pre-write hook on &lt;code dir=&quot;auto&quot;&gt;DocumentStoreOptions&lt;/code&gt;. &lt;code dir=&quot;auto&quot;&gt;AutoEmbedOnInsert&amp;#x3C;T&gt;&lt;/code&gt; is the headline consumer but it’s a general “compute derived fields” extension point.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;SupportsVector&lt;/code&gt;&lt;/strong&gt; on &lt;code dir=&quot;auto&quot;&gt;IDocumentStore&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;IDatabaseProvider&lt;/code&gt;, matching the existing &lt;code dir=&quot;auto&quot;&gt;SupportsSpatial&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;PostgreSQL optimistic concurrency fix — the version check now extracts as a typed int (&lt;code dir=&quot;auto&quot;&gt;::BIGINT&lt;/code&gt;), no more &lt;code dir=&quot;auto&quot;&gt;42883: operator does not exist&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;PostgreSQL and DuckDB multi-tenancy fix — the &lt;code dir=&quot;auto&quot;&gt;CAST(@data AS JSONB)&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;CAST(@data AS JSON)&lt;/code&gt; envelopes no longer break the tenant-column rewrite.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;upgrading&quot;&gt;Upgrading&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;v6 is API-compatible with v5 in every place that matters. The semaphore on the server-SQL providers is gone — if you relied on it to serialize writes from one store instance, you’ll want to switch to &lt;code dir=&quot;auto&quot;&gt;RunInTransaction&lt;/code&gt; for that semantics. Everything else is purely additive.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.DocumentDb.PostgreSql&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--version&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;6.0.0&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.DocumentDb.Sqlite&lt;/span&gt;&lt;span&gt;     &lt;/span&gt;&lt;span&gt;--version&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;6.0.0&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.DocumentDb.SqlServer&lt;/span&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;--version&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;6.0.0&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# etc.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/release-notes/&quot;&gt;Full release notes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/vector/&quot;&gt;Vector search&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/query-filters/&quot;&gt;Global query filters&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.shinylib.net/documentdb/change-monitoring/&quot;&gt;Change monitoring&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/shinyorg/DocumentDb&quot;&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>documentdb</category><category>release</category><category>AI</category><category>vector</category></item><item><title>Introducing Shiny.AiConversation — AI Conversation</title><link>https://www.shinylib.net/blog/2026/05/ai-conversation/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/05/ai-conversation/</guid><pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;a href=&quot;https://www.nuget.org/packages/Shiny.AiConversation&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.AiConversation?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.AiConversation&quot; alt=&quot;NuGet package Shiny.AiConversation&quot;&gt;&lt;/a&gt;
&lt;aside aria-label=&quot;Early Beta&quot;&gt;&lt;p aria-hidden=&quot;true&quot;&gt;Early Beta&lt;/p&gt;&lt;div&gt;&lt;p&gt;This library is in active development. APIs may change, and some features are still being stabilized. Feedback and bug reports are very welcome, but don’t ship it to production just yet.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;
&lt;p&gt;Building an AI-powered app today means stitching together a chat client, speech recognition, text-to-speech, audio playback, message persistence, and state management — across platforms, with proper lifecycle handling.  That’s a lot of plumbing before you write your first prompt.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Shiny.AiConversation&lt;/strong&gt; wraps all of that into a single &lt;code dir=&quot;auto&quot;&gt;IAiConversationService&lt;/code&gt; interface.  Text chat, voice chat, hands-free wake word activation, configurable audio feedback, and persistent chat history — registered with one DI call, consumed through one service.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;the-problem&quot;&gt;The Problem&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Every AI chat app ends up building the same infrastructure:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An authenticated chat client that handles token refresh&lt;/li&gt;
&lt;li&gt;Speech-to-text so users can talk instead of type&lt;/li&gt;
&lt;li&gt;Text-to-speech so the AI can respond out loud&lt;/li&gt;
&lt;li&gt;Sound effects for state transitions (thinking, responding, error)&lt;/li&gt;
&lt;li&gt;A wake word listener for hands-free mode&lt;/li&gt;
&lt;li&gt;Message persistence for chat history&lt;/li&gt;
&lt;li&gt;State management so the UI knows what’s happening&lt;/li&gt;
&lt;li&gt;Thread safety so nothing blows up&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each of these is a separate library, a separate abstraction, and a separate set of platform quirks.  You spend weeks on infrastructure before you ship a single feature.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-solution&quot;&gt;The Solution&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Register your chat client in DI&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddChatClient&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;OpenAIClient&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;your-api-key&quot;&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;GetChatClient&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;gpt-4o&quot;&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;AsIChatClient&lt;/span&gt;&lt;span&gt;());&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddShinyAiConversation&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;opts&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;opts.&lt;/span&gt;&lt;span&gt;SetMessageStore&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;MyMessageStore&lt;/span&gt;&lt;span&gt;&gt;(); &lt;/span&gt;&lt;span&gt;// optional&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s it.  The service registers &lt;code dir=&quot;auto&quot;&gt;IAiConversationService&lt;/code&gt; with all the wiring — speech services from &lt;a href=&quot;https://www.shinylib.net/speech/&quot;&gt;Shiny.Speech&lt;/a&gt;, chat completions from &lt;a href=&quot;https://www.nuget.org/packages/Microsoft.Extensions.AI&quot;&gt;Microsoft.Extensions.AI&lt;/a&gt;, audio playback, time provider, and optional message persistence.  The default &lt;code dir=&quot;auto&quot;&gt;IChatClientProvider&lt;/code&gt; resolves &lt;code dir=&quot;auto&quot;&gt;IChatClient&lt;/code&gt; straight from DI, so for most apps you just register your chat client and go.  For advanced scenarios (on-demand auth, token refresh), you can still implement &lt;code dir=&quot;auto&quot;&gt;IChatClientProvider&lt;/code&gt; directly.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-you-can-do&quot;&gt;What You Can Do&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;text-chat&quot;&gt;Text Chat&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The simplest path.  Send a message, get a streaming response:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;aiService.AiResponded &lt;/span&gt;&lt;span&gt;+=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;response&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (response.Response.Text &lt;/span&gt;&lt;span&gt;is&lt;/span&gt;&lt;span&gt; { } &lt;/span&gt;&lt;span&gt;text&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Console.&lt;/span&gt;&lt;span&gt;WriteLine&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;$&quot;AI: &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;text&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;};&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; aiService.&lt;/span&gt;&lt;span&gt;TalkTo&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;What is .NET MAUI?&quot;&lt;/span&gt;&lt;span&gt;, cancellationToken);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The service handles the full lifecycle — acquires the chat client, prepends system prompts, streams the response, stores both messages if a message store is configured, fires the event, and manages state transitions throughout.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;voice-chat-push-to-talk&quot;&gt;Voice Chat (Push-to-Talk)&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;One method call captures speech and sends it to the AI:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; aiService.&lt;/span&gt;&lt;span&gt;ListenAndTalk&lt;/span&gt;&lt;span&gt;(cancellationToken);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The service activates speech-to-text, waits for the user to stop speaking, sends the transcribed text through &lt;code dir=&quot;auto&quot;&gt;TalkTo()&lt;/code&gt;, and optionally reads the response aloud via text-to-speech.  If the AI responds with a question, the service automatically keeps listening for the user’s reply — creating a natural back-and-forth conversation without requiring another button press.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;hands-free-wake-word&quot;&gt;Hands-Free Wake Word&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;This is the “Hey Siri” experience:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; aiService.&lt;/span&gt;&lt;span&gt;StartWakeWord&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Hey Copilot&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The service enters a continuous loop: listen for the wake phrase, capture the utterance that follows, send it to the AI, loop back.  If the AI asks a follow-up question, the loop skips wake word detection and listens directly for the user’s reply.  The user never touches the screen.  Call &lt;code dir=&quot;auto&quot;&gt;StopWakeWord()&lt;/code&gt; when you’re done.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;four-acknowledgement-modes&quot;&gt;Four Acknowledgement Modes&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Control how the AI delivers responses:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;What Happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;None&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Silent — text only, delivered via the &lt;code dir=&quot;auto&quot;&gt;AiResponded&lt;/code&gt; event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;AudioBlip&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Short sound effects at each state transition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;LessWordy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Text-to-speech with a “be concise” system prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Full&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full text-to-speech of the complete response&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Sound effects are driven by string file names and a &lt;code dir=&quot;auto&quot;&gt;SoundResolver&lt;/code&gt; callback — the library stays platform-agnostic while you provide the stream:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;aiService.SoundResolver &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; FileSystem.&lt;/span&gt;&lt;span&gt;OpenAppPackageFileAsync&lt;/span&gt;&lt;span&gt;(name);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;aiService.ThinkSound &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;think.mp3&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;aiService.OkSound &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;ok.mp3&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;voice-interruption&quot;&gt;Voice Interruption&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;During TTS playback, the service listens for voice interruptions.  Say a “quiet word” like “stop” or “cancel” and TTS is silenced immediately, breaking out of the conversation.  Say anything else and TTS stops, but your new utterance is sent to the AI as the next message — the conversation continues seamlessly.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Default quiet words: cancel, quiet, shut up, stop, nevermind, never mind, hush&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Customize or disable:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;aiService.QuietWords &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;stop&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;cancel&quot;&lt;/span&gt;&lt;span&gt;];&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;aiService.QuietWords &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;// disable interruption&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Quiet words only trigger when they’re the user’s &lt;em&gt;entire&lt;/em&gt; utterance.  “Cancel this appointment” won’t interrupt — it’ll be treated as a new message to the AI.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;chat-history-with-ai-self-lookup&quot;&gt;Chat History with AI Self-Lookup&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Register an &lt;code dir=&quot;auto&quot;&gt;IMessageStore&lt;/code&gt; and every message is automatically persisted.  But the interesting part is the &lt;strong&gt;AI chat lookup tool&lt;/strong&gt; — it’s an &lt;code dir=&quot;auto&quot;&gt;AITool&lt;/code&gt; that lets the AI search its own conversation history:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;“What did we talk about yesterday?”&lt;/em&gt;
&lt;em&gt;“Find the recipe you gave me last week.”&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The tool is registered automatically when you call &lt;code dir=&quot;auto&quot;&gt;SetMessageStore()&lt;/code&gt;.  The AI gets search parameters (text, date range, limit) and queries your store directly.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;observable-state&quot;&gt;Observable State&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;The service exposes its current state and fires events:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;aiService.StatusChanged &lt;/span&gt;&lt;span&gt;+=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;state&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;// state: Idle, Listening, Thinking, Responding&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;UpdateUI&lt;/span&gt;&lt;span&gt;(state);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;};&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;This is what powers the “Aura” visualization in our sample app — a pulsing orb that changes color based on what the AI is doing.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;bring-your-own-backend&quot;&gt;Bring Your Own Backend&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The library doesn’t care which AI you use.  By default, it resolves &lt;code dir=&quot;auto&quot;&gt;IChatClient&lt;/code&gt; from DI — just register one and you’re done.  For advanced auth scenarios, implement &lt;code dir=&quot;auto&quot;&gt;IChatClientProvider&lt;/code&gt; to return any &lt;code dir=&quot;auto&quot;&gt;IChatClient&lt;/code&gt; from &lt;code dir=&quot;auto&quot;&gt;Microsoft.Extensions.AI&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;OpenAI&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;new OpenAIClient(apiKey).GetChatClient(&quot;gpt-4o&quot;).AsIChatClient()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitHub Copilot&lt;/strong&gt; — OAuth device code flow with Copilot API token exchange&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Azure OpenAI&lt;/strong&gt; — Managed identity or API key&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ollama&lt;/strong&gt; — Local model, no auth needed&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Anything else&lt;/strong&gt; — If it implements &lt;code dir=&quot;auto&quot;&gt;IChatClient&lt;/code&gt;, it works&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The sample apps include a complete GitHub Copilot implementation with device code flow, token caching, automatic re-authentication, and the custom HTTP headers the Copilot API requires.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;cross-platform-from-day-one&quot;&gt;Cross-Platform from Day One&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The library targets plain &lt;code dir=&quot;auto&quot;&gt;net10.0&lt;/code&gt; — no MAUI dependency in the library itself.  &lt;a href=&quot;https://www.shinylib.net/speech/&quot;&gt;Shiny.Speech&lt;/a&gt; handles the platform abstraction for speech and audio, so the same &lt;code dir=&quot;auto&quot;&gt;IAiConversationService&lt;/code&gt; works on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;MAUI&lt;/strong&gt; — Android, iOS, Windows, Mac Catalyst&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Blazor&lt;/strong&gt; — Server-side and WebAssembly (speech via Web Audio API)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We ship two sample apps that prove it: a full &lt;a href=&quot;https://github.com/shinyorg/speech/tree/main/samples/MauiSample&quot;&gt;MAUI sample&lt;/a&gt; with chat, settings, and an animated aura visualization, plus a &lt;a href=&quot;https://github.com/shinyorg/speech/tree/main/samples/BlazorSample&quot;&gt;Blazor sample&lt;/a&gt; with the same features translated to Razor components and CSS animations.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;aot-compatible&quot;&gt;AOT Compatible&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The library is built with &lt;code dir=&quot;auto&quot;&gt;IsAotCompatible=true&lt;/code&gt;.  Generic type parameters on &lt;code dir=&quot;auto&quot;&gt;SetChatClientProvider&amp;#x3C;T&gt;()&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;SetMessageStore&amp;#x3C;T&gt;()&lt;/code&gt; carry &lt;code dir=&quot;auto&quot;&gt;[DynamicallyAccessedMembers]&lt;/code&gt; attributes so the trimmer knows what to keep.  No reflection surprises at runtime.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;get-started&quot;&gt;Get Started&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;add&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;package&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Shiny.AiConversation&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.shinylib.net/aiconversation/&quot;&gt;Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/shinyorg/speech&quot;&gt;GitHub Repository&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/shinyorg/speech/tree/main/samples/MauiSample&quot;&gt;MAUI Sample&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/shinyorg/speech/tree/main/samples/BlazorSample&quot;&gt;Blazor Sample&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The library is MIT licensed and open source.  We’d love to hear what you build with it.&lt;/p&gt;</content:encoded><category>Release</category><category>AI</category></item><item><title>The Feedback Service — One Hook to Rule Them All</title><link>https://www.shinylib.net/blog/2026/05/feedback-service/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/05/feedback-service/</guid><pubDate>Sun, 03 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;a href=&quot;https://www.nuget.org/packages/Shiny.Maui.Controls&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;&lt;img src=&quot;https://img.shields.io/nuget/v/Shiny.Maui.Controls?style=for-the-badge&amp;#x26;logo=nuget&amp;#x26;label=Shiny.Maui.Controls&quot; alt=&quot;NuGet package Shiny.Maui.Controls&quot;&gt;&lt;/a&gt;
&lt;p&gt;Every tap, swipe, and keystroke in your app is an opportunity.  An opportunity to confirm the user’s action, guide their attention, or add a layer of polish that separates “functional” from “delightful.”  Most apps handle this with scattered &lt;code dir=&quot;auto&quot;&gt;HapticFeedback.Default.Perform()&lt;/code&gt; calls sprinkled across code-behind files.  It works — until you want text-to-speech for accessibility, sound effects for a kiosk app, analytics for product telemetry, or different feedback for different controls.  Then you’re threading conditional logic through every view in your app.&lt;/p&gt;
&lt;p&gt;Shiny Controls v1.0 ships with &lt;code dir=&quot;auto&quot;&gt;IFeedbackService&lt;/code&gt; — a single injectable service that every interactive control in the library already calls.  You implement it once.  Every control uses it automatically.&lt;/p&gt;
&lt;hr&gt;
&lt;div&gt;&lt;h2 id=&quot;how-it-works&quot;&gt;How It Works&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Every Shiny control that supports feedback has a &lt;code dir=&quot;auto&quot;&gt;UseFeedback&lt;/code&gt; property (default: &lt;code dir=&quot;auto&quot;&gt;true&lt;/code&gt;).  When a user interaction occurs — a message sent, a pin digit entered, a panel opened — the control calls &lt;code dir=&quot;auto&quot;&gt;IFeedbackService.OnRequested()&lt;/code&gt; with three things:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;interface&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;IFeedbackService&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;void&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;OnRequested&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;control&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;eventName&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt;? &lt;/span&gt;&lt;span&gt;args&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;control&lt;/code&gt;&lt;/strong&gt; — the actual control instance, not a &lt;code dir=&quot;auto&quot;&gt;Type&lt;/code&gt;.  Pattern match directly: &lt;code dir=&quot;auto&quot;&gt;control is ChatView&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;control is SecurityPin&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;eventName&lt;/code&gt;&lt;/strong&gt; — what happened: &lt;code dir=&quot;auto&quot;&gt;&quot;MessageReceived&quot;&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;&quot;DigitEntered&quot;&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;&quot;Opened&quot;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;args&lt;/code&gt;&lt;/strong&gt; — contextual data.  For &lt;code dir=&quot;auto&quot;&gt;ChatView&lt;/code&gt;, this is the full &lt;code dir=&quot;auto&quot;&gt;ChatMessage&lt;/code&gt; object.  For standard MAUI controls, it’s the native &lt;code dir=&quot;auto&quot;&gt;EventArgs&lt;/code&gt;.  For &lt;code dir=&quot;auto&quot;&gt;SecurityPin&lt;/code&gt; completion, it’s &lt;code dir=&quot;auto&quot;&gt;&quot;LongPress&quot;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The default &lt;code dir=&quot;auto&quot;&gt;HapticFeedbackService&lt;/code&gt; does what you’d expect — click haptic for most events, long press haptic for completion events.  But the real power is in replacing it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;custom-feedback-tts--sound-effects&quot;&gt;Custom Feedback: TTS + Sound Effects&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Here’s a real example from our sample app.  One service, three behaviors — haptic, text-to-speech for incoming chat messages, and audio cues for PIN entry:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;MyCustomFeedbackService&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;ITextToSpeechService&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;textToSpeech&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;IAudioManager&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;audioManager&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;) : &lt;/span&gt;&lt;span&gt;HapticFeedbackService&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;override&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;void&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;OnRequested&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;control&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;eventName&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt;? &lt;/span&gt;&lt;span&gt;args&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;// haptic first — always&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;base&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;OnRequested&lt;/span&gt;&lt;span&gt;(control, eventName, args);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;// speak incoming chat messages aloud&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (control &lt;/span&gt;&lt;span&gt;is&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ChatView&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span&gt; args &lt;/span&gt;&lt;span&gt;is&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ChatMessage&lt;/span&gt;&lt;span&gt; { IsFromMe: &lt;/span&gt;&lt;span&gt;false&lt;/span&gt;&lt;span&gt; } &lt;/span&gt;&lt;span&gt;msg&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; textToSpeech.&lt;/span&gt;&lt;span&gt;SpeakAsync&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                &lt;/span&gt;&lt;span&gt;$&quot;Message from &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;msg&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;SenderId&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;. &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;msg&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Text&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;// click and success sounds for PIN entry&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;else&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (control &lt;/span&gt;&lt;span&gt;is&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;SecurityPin&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sound&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; eventName.&lt;/span&gt;&lt;span&gt;Equals&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;completed&quot;&lt;/span&gt;&lt;span&gt;, StringComparison.OrdinalIgnoreCase)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                &lt;/span&gt;&lt;span&gt;?&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;pin_success.wav&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                &lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;pin_click.wav&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;raw&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; FileSystem.&lt;/span&gt;&lt;span&gt;OpenAppPackageFileAsync&lt;/span&gt;&lt;span&gt;(sound);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;audioManager.&lt;/span&gt;&lt;span&gt;CreatePlayer&lt;/span&gt;&lt;span&gt;(raw).&lt;/span&gt;&lt;span&gt;Play&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Register it in one line:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.&lt;/span&gt;&lt;span&gt;UseShinyControls&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;cfg&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;cfg.&lt;/span&gt;&lt;span&gt;SetCustomFeedback&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;MyCustomFeedbackService&lt;/span&gt;&lt;span&gt;&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Because &lt;code dir=&quot;auto&quot;&gt;control&lt;/code&gt; is the live instance and &lt;code dir=&quot;auto&quot;&gt;args&lt;/code&gt; carries typed data, you can make nuanced decisions without parsing strings.  The &lt;code dir=&quot;auto&quot;&gt;ChatMessage&lt;/code&gt; gives you sender, timestamp, text, and image URL.  The &lt;code dir=&quot;auto&quot;&gt;SecurityPin&lt;/code&gt; instance gives you its current value and length.  Cast, match, and go.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;pluggable-maui-control-hooks&quot;&gt;Pluggable MAUI Control Hooks&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Shiny’s own controls call &lt;code dir=&quot;auto&quot;&gt;IFeedbackService&lt;/code&gt; internally.  But what about standard MAUI controls — &lt;code dir=&quot;auto&quot;&gt;Button&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Slider&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Entry&lt;/code&gt;?  The &lt;code dir=&quot;auto&quot;&gt;MauiControlFeedbackBuilder&lt;/code&gt; hooks them in automatically, with an AOT-compatible, fully pluggable design:&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;all-defaults&quot;&gt;All defaults&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;cfg.&lt;/span&gt;&lt;span&gt;AddDefaultMauiControlFeedback&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;This registers hooks for 12 standard MAUI controls — &lt;code dir=&quot;auto&quot;&gt;Button.Clicked&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Entry.TextChanged&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Slider.ValueChanged&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Switch.Toggled&lt;/code&gt;, and more.  Each hook passes the control instance as &lt;code dir=&quot;auto&quot;&gt;control&lt;/code&gt; and the native event args as &lt;code dir=&quot;auto&quot;&gt;args&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;defaults--your-own&quot;&gt;Defaults + your own&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;cfg.&lt;/span&gt;&lt;span&gt;AddDefaultMauiControlFeedback&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;x.&lt;/span&gt;&lt;span&gt;Hook&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;MyCustomControl&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;nameof&lt;/span&gt;&lt;span&gt;(MyCustomControl.Tapped),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;h&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; c.Tapped &lt;/span&gt;&lt;span&gt;+=&lt;/span&gt;&lt;span&gt; h,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;h&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; c.Tapped &lt;/span&gt;&lt;span&gt;-=&lt;/span&gt;&lt;span&gt; h);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;only-what-you-need&quot;&gt;Only what you need&lt;/h3&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;cfg.&lt;/span&gt;&lt;span&gt;AddMauiControlFeedback&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;x.&lt;/span&gt;&lt;span&gt;Hook&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Button&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;nameof&lt;/span&gt;&lt;span&gt;(Button.Clicked),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;btn&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;h&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; btn.Clicked &lt;/span&gt;&lt;span&gt;+=&lt;/span&gt;&lt;span&gt; h,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;btn&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;h&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; btn.Clicked &lt;/span&gt;&lt;span&gt;-=&lt;/span&gt;&lt;span&gt; h);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;x.&lt;/span&gt;&lt;span&gt;Hook&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Slider&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;ValueChangedEventArgs&lt;/span&gt;&lt;span&gt;&gt;(&lt;/span&gt;&lt;span&gt;nameof&lt;/span&gt;&lt;span&gt;(Slider.ValueChanged),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;s&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;h&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; s.ValueChanged &lt;/span&gt;&lt;span&gt;+=&lt;/span&gt;&lt;span&gt; h,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;s&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;h&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; s.ValueChanged &lt;/span&gt;&lt;span&gt;-=&lt;/span&gt;&lt;span&gt; h);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Two overloads cover every case:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;Hook&amp;#x3C;TControl&gt;(eventName, subscribe, unsubscribe)&lt;/code&gt; for plain &lt;code dir=&quot;auto&quot;&gt;EventHandler&lt;/code&gt; events&lt;/li&gt;
&lt;li&gt;&lt;code dir=&quot;auto&quot;&gt;Hook&amp;#x3C;TControl, TEventArgs&gt;(eventName, subscribe, unsubscribe)&lt;/code&gt; for typed &lt;code dir=&quot;auto&quot;&gt;EventHandler&amp;#x3C;TEventArgs&gt;&lt;/code&gt; events&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Under the hood, each hook uses a &lt;code dir=&quot;auto&quot;&gt;ConditionalWeakTable&lt;/code&gt; to track handlers per control instance — no leaks, no dictionaries to manage, proper unsubscription when controls leave the visual tree.  Zero reflection, fully AOT-safe.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-ships-built-in&quot;&gt;What Ships Built-In&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Every Shiny control fires feedback through this system.  Here’s the full event catalog:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;th&gt;Events&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ChatView&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;MessageSent&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MessageReceived&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;MessageTapped&lt;/code&gt; (all pass &lt;code dir=&quot;auto&quot;&gt;ChatMessage&lt;/code&gt;), &lt;code dir=&quot;auto&quot;&gt;AttachImage&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SecurityPin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;DigitEntered&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Completed&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FloatingPanel&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Opened&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Closed&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;DetentChanged&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ImageViewer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Opened&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Closed&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;DoubleTapped&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ImageEditor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;ToolModeChanged&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Undo&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Redo&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Rotate&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Reset&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;CropApplied&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Saved&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fab / FabMenu&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Clicked&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Toggled&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scheduler&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;DaySelected&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;EventSelected&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;TimeSlotSelected&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TableView Cells&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Tapped&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Toast&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Show&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Any control’s feedback can be suppressed per-instance with &lt;code dir=&quot;auto&quot;&gt;UseFeedback=&quot;False&quot;&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-design-philosophy&quot;&gt;The Design Philosophy&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Most feedback systems are either too simple (a global haptic toggle) or too complex (per-control event subscriptions scattered across your app).  &lt;code dir=&quot;auto&quot;&gt;IFeedbackService&lt;/code&gt; sits in the sweet spot:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;One service, all controls.&lt;/strong&gt;  Implement once, every control calls it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Instance, not type.&lt;/strong&gt;  You get the actual control, not &lt;code dir=&quot;auto&quot;&gt;typeof(Button)&lt;/code&gt;.  Inspect properties, check state, make decisions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Typed args, not strings.&lt;/strong&gt;  &lt;code dir=&quot;auto&quot;&gt;ChatMessage&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;ValueChangedEventArgs&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;ToggledEventArgs&lt;/code&gt; — not &lt;code dir=&quot;auto&quot;&gt;&quot;the message text&quot;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pluggable hooks, not hardcoded events.&lt;/strong&gt;  Add your own controls to the system with three lambdas.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AOT-safe.&lt;/strong&gt;  No reflection, no expressions, no &lt;code dir=&quot;auto&quot;&gt;Delegate.CreateDelegate&lt;/code&gt;.  Just generics and delegates.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Whether you’re building an accessible app that speaks every incoming message, a kiosk that plays sound effects, or just want consistent haptic feedback across your entire UI — &lt;code dir=&quot;auto&quot;&gt;IFeedbackService&lt;/code&gt; is one implementation away.&lt;/p&gt;
&lt;p&gt;Check out the &lt;a href=&quot;https://www.shinylib.net/controls/feedback/&quot;&gt;full documentation&lt;/a&gt; and the sample app for a working demo with TTS and audio integration.&lt;/p&gt;</content:encoded><category>Controls</category><category>Release</category></item><item><title>DocumentDb AI Tools — Give Your LLM a Database</title><link>https://www.shinylib.net/blog/2026/04/documentdb-ai-tools/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/04/documentdb-ai-tools/</guid><description>Shiny.DocumentDb.Extensions.AI turns your document store into a set of LLM-callable tools. Register types, set capabilities, and let the agent query, insert, update, and delete documents through natural language.</description><pubDate>Thu, 30 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;You have a document store with customers, orders, and products. Now you want an LLM agent to answer questions about that data — or even modify it — without writing custom glue code for every operation. &lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Extensions.AI&lt;/code&gt; makes that a one-time setup.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-it-does&quot;&gt;What It Does&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The new &lt;code dir=&quot;auto&quot;&gt;Shiny.DocumentDb.Extensions.AI&lt;/code&gt; package generates &lt;code dir=&quot;auto&quot;&gt;Microsoft.Extensions.AI&lt;/code&gt; tool functions from your registered document types. Each type can expose up to seven operations: get by ID, query with structured filters, count, aggregate (sum/min/max/avg), insert, update, and delete.&lt;/p&gt;
&lt;p&gt;You control everything: which types are visible to the LLM, which operations are allowed, which fields are exposed, and how many results a single query can return.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;setup-in-30-seconds&quot;&gt;Setup in 30 Seconds&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;jsonContext&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AppJsonContext&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;JsonSerializerOptions&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;PropertyNamingPolicy &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; JsonNamingPolicy.CamelCase&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;services.&lt;/span&gt;&lt;span&gt;AddDocumentStore&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;opts&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;opts.DatabaseProvider &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;SqliteDatabaseProvider&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Data Source=mydata.db&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;opts.JsonSerializerOptions &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; jsonContext.Options;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;services.&lt;/span&gt;&lt;span&gt;AddDocumentStoreAITools&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;tools&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;tools.&lt;/span&gt;&lt;span&gt;AddType&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;jsonContext.Customer,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;capabilities&lt;/span&gt;&lt;span&gt;: DocumentAICapabilities.All,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;configure&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;b&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; b&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Description&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Customer records&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Property&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; c.Status, &lt;/span&gt;&lt;span&gt;&quot;Active, Inactive, or Suspended&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;IgnoreProperties&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; c.PasswordHash)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;MaxPageSize&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;50&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;tools.&lt;/span&gt;&lt;span&gt;AddType&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;jsonContext.Order,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;capabilities&lt;/span&gt;&lt;span&gt;: DocumentAICapabilities.ReadOnly&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That registers 11 tools: 7 for Customer (full CRUD) and 4 for Order (read-only). Types not registered are invisible to the LLM.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;structured-filters&quot;&gt;Structured Filters&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The query, count, and aggregate tools accept a structured filter that supports boolean combinators — &lt;code dir=&quot;auto&quot;&gt;and&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;or&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;not&lt;/code&gt; — and leaf comparisons with operators like &lt;code dir=&quot;auto&quot;&gt;eq&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;gt&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;contains&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;startsWith&lt;/code&gt;, and &lt;code dir=&quot;auto&quot;&gt;in&lt;/code&gt;. The library translates these JSON filter objects into LINQ expressions against the document store, so they work across all providers (SQLite, MySQL, SQL Server, PostgreSQL).&lt;/p&gt;
&lt;p&gt;When an LLM asks “show me customers older than 30 in Portland”, it constructs:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;and&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ &lt;/span&gt;&lt;span&gt;&quot;field&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;age&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;op&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;gt&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;value&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;30&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ &lt;/span&gt;&lt;span&gt;&quot;field&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;city&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;op&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;eq&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&quot;value&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;Portland&quot;&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;This becomes &lt;code dir=&quot;auto&quot;&gt;store.Query&amp;#x3C;Customer&gt;().Where(c =&gt; c.Age &gt; 30 &amp;#x26;&amp;#x26; c.City == &quot;Portland&quot;)&lt;/code&gt; under the hood.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;field-visibility&quot;&gt;Field Visibility&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Not every field should be visible to an LLM agent. Use &lt;code dir=&quot;auto&quot;&gt;AllowProperties&lt;/code&gt; to create an allowlist or &lt;code dir=&quot;auto&quot;&gt;IgnoreProperties&lt;/code&gt; to hide specific fields:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Only expose these fields&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;b.&lt;/span&gt;&lt;span&gt;AllowProperties&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; c.Id, &lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; c.Name, &lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; c.Email);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Or hide sensitive fields&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;b.&lt;/span&gt;&lt;span&gt;IgnoreProperties&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; c.PasswordHash, &lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; c.InternalNotes);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Hidden fields don’t appear in the tool’s JSON schema, so the LLM doesn’t know they exist and can’t filter on them.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;capability-flags&quot;&gt;Capability Flags&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;DocumentAICapabilities&lt;/code&gt; flags enum gives you precise control:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Read-only: get, query, count, aggregate&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;tools.&lt;/span&gt;&lt;span&gt;AddType&lt;/span&gt;&lt;span&gt;(jsonContext.AuditLog, &lt;/span&gt;&lt;span&gt;capabilities&lt;/span&gt;&lt;span&gt;: DocumentAICapabilities.ReadOnly);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Full CRUD&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;tools.&lt;/span&gt;&lt;span&gt;AddType&lt;/span&gt;&lt;span&gt;(jsonContext.Customer, &lt;/span&gt;&lt;span&gt;capabilities&lt;/span&gt;&lt;span&gt;: DocumentAICapabilities.All);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Just query and count&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;tools.&lt;/span&gt;&lt;span&gt;AddType&lt;/span&gt;&lt;span&gt;(jsonContext.Config, &lt;/span&gt;&lt;span&gt;capabilities&lt;/span&gt;&lt;span&gt;: DocumentAICapabilities.Query &lt;/span&gt;&lt;span&gt;|&lt;/span&gt;&lt;span&gt; DocumentAICapabilities.Count);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;aot-safe&quot;&gt;AOT-Safe&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;All tool schemas and serialization use &lt;code dir=&quot;auto&quot;&gt;JsonTypeInfo&amp;#x3C;T&gt;&lt;/code&gt; from your source-generated JSON context. No reflection at runtime. The filter translator builds expression trees programmatically — it never calls &lt;code dir=&quot;auto&quot;&gt;Expression.Compile()&lt;/code&gt; — so the existing JSON-extract SQL translator handles AOT-safe code generation.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;try-the-copilot-sample&quot;&gt;Try the Copilot Sample&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The repository includes a &lt;code dir=&quot;auto&quot;&gt;Sample.CopilotConsole&lt;/code&gt; project that authenticates with GitHub Copilot and starts an interactive chat session. The LLM can query customers, create orders, compute aggregates — all through the registered AI tools. Try it:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;cd&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;samples/Sample.CopilotConsole&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;dotnet&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Then ask things like “How many customers do we have?”, “Show me all shipped orders”, or “Add a customer named Eve, age 28”.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;getting-started&quot;&gt;Getting Started&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Check the &lt;a href=&quot;https://www.shinylib.net/documentdb/ai-tools/&quot;&gt;AI Tools documentation&lt;/a&gt; for the full API reference and the &lt;a href=&quot;https://www.shinylib.net/documentdb/release-notes/&quot;&gt;release notes&lt;/a&gt; for the complete v4.0 changelog.&lt;/p&gt;</content:encoded><category>documentdb</category><category>AI</category><category>release</category></item><item><title>Turn Any Interface Into an AI Tool — Shiny DI 3.0</title><link>https://www.shinylib.net/blog/2026/04/di-ai-tools/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/04/di-ai-tools/</guid><pubDate>Wed, 29 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;What if every service interface you already have could become an AI tool with a single attribute? Shiny Extensions DI 3.0 makes that happen — no adapter classes, no hand-rolled schemas, no registration boilerplate. Mark your interface with &lt;code dir=&quot;auto&quot;&gt;[Tool]&lt;/code&gt;, add &lt;code dir=&quot;auto&quot;&gt;[Description]&lt;/code&gt; to the methods that matter, and the source generator handles the rest.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-problem&quot;&gt;The Problem&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;You’ve built your services. Clean interfaces, proper DI registration, everything wired up. Now someone asks you to expose a few of those operations as AI tools for an LLM agent. Suddenly you’re writing &lt;code dir=&quot;auto&quot;&gt;AIFunction&lt;/code&gt; subclasses by hand — one per operation — each with a constructor that takes the service, a metadata property with hand-written parameter schemas, and an &lt;code dir=&quot;auto&quot;&gt;InvokeCoreAsync&lt;/code&gt; override that extracts arguments from a dictionary and forwards them to your service method.&lt;/p&gt;
&lt;p&gt;For one or two tools, it’s fine. For ten or twenty, it’s tedious. And every time you change a method signature, you have to remember to update the corresponding tool class. The schema drifts, the argument parsing breaks, and the bugs only show up when the LLM calls the tool at runtime.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-solution-tool--description&quot;&gt;The Solution: &lt;code dir=&quot;auto&quot;&gt;[Tool]&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;[Description]&lt;/code&gt;&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;Tool&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;Description&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Manages customer orders&quot;&lt;/span&gt;&lt;span&gt;)]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;interface&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;IOrderService&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;Description&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Places a new order for a customer&quot;&lt;/span&gt;&lt;span&gt;)]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;OrderResult&lt;/span&gt;&lt;span&gt;&gt; &lt;/span&gt;&lt;span&gt;PlaceOrderAsync&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;Description&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;The customer identifier&quot;&lt;/span&gt;&lt;span&gt;)] &lt;/span&gt;&lt;span&gt;Guid&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;customerId&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;Description&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;The product SKU&quot;&lt;/span&gt;&lt;span&gt;)] &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sku&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;Description&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Number of units to order&quot;&lt;/span&gt;&lt;span&gt;)] &lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;quantity&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;Description&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Cancels an existing order&quot;&lt;/span&gt;&lt;span&gt;)]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;CancelOrderAsync&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;Description&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;The order to cancel&quot;&lt;/span&gt;&lt;span&gt;)] &lt;/span&gt;&lt;span&gt;Guid&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;orderId&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;Description&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Reason for cancellation&quot;&lt;/span&gt;&lt;span&gt;)] &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;reason&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;// No [Description] — not exposed as a tool&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;List&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Order&lt;/span&gt;&lt;span&gt;&gt;&gt; &lt;/span&gt;&lt;span&gt;GetInternalAuditLogAsync&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s it. The source generator produces a fully typed &lt;code dir=&quot;auto&quot;&gt;AIFunction&lt;/code&gt; subclass for each described method, wires up the parameter metadata, and generates a registration extension — all at compile time.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-gets-generated&quot;&gt;What Gets Generated&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;For &lt;code dir=&quot;auto&quot;&gt;PlaceOrderAsync&lt;/code&gt; above, the generator emits a class like this:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;IOrderServicePlaceOrderAsyncAITool&lt;/span&gt;&lt;span&gt; : &lt;/span&gt;&lt;span&gt;AIFunction&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;private&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;readonly&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;IOrderService&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;_service&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;private&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;static&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;readonly&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AIFunctionMetadata&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;_metadata&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AIFunctionMetadata&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;IOrderServicePlaceOrderAsync&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Description &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;Places a new order for a customer&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Parameters &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AIFunctionParameterMetadata&lt;/span&gt;&lt;span&gt;[]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;customerId&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Description &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;The customer identifier&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;ParameterType &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;typeof&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;Guid&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;IsRequired &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;sku&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Description &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;The product SKU&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;ParameterType &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;typeof&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;IsRequired &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;quantity&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Description &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;Number of units to order&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;ParameterType &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;typeof&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt;),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;IsRequired &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;};&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Guid&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;CustomerId&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;set&lt;/span&gt;&lt;span&gt;; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Sku&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;set&lt;/span&gt;&lt;span&gt;; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;int&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Quantity&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;set&lt;/span&gt;&lt;span&gt;; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;IOrderServicePlaceOrderAsyncAITool&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;IOrderService&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;service&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;_service &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; service;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;override&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AIFunctionMetadata&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Metadata&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; _metadata;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;protected&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;override&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt;?&gt; &lt;/span&gt;&lt;span&gt;InvokeCoreAsync&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;IEnumerable&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;KeyValuePair&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt;?&gt;&gt;? &lt;/span&gt;&lt;span&gt;arguments&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;CancellationToken&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;cancellationToken&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;// argument extraction and service call&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; _service.&lt;/span&gt;&lt;span&gt;PlaceOrderAsync&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.CustomerId, &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.Sku, &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.Quantity);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A second class is generated for &lt;code dir=&quot;auto&quot;&gt;CancelOrderAsync&lt;/code&gt;. The &lt;code dir=&quot;auto&quot;&gt;GetInternalAuditLogAsync&lt;/code&gt; method is skipped because it has no &lt;code dir=&quot;auto&quot;&gt;[Description]&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;registration&quot;&gt;Registration&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;All generated tools are registered with a single call:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;services.&lt;/span&gt;&lt;span&gt;AddGeneratedAITools&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;This registers each tool as &lt;code dir=&quot;auto&quot;&gt;Transient&amp;#x3C;AITool, GeneratedToolClass&gt;&lt;/code&gt;. You can then resolve all tools and pass them to any &lt;code dir=&quot;auto&quot;&gt;IChatClient&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;tools&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; serviceProvider.&lt;/span&gt;&lt;span&gt;GetServices&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;AITool&lt;/span&gt;&lt;span&gt;&gt;().&lt;/span&gt;&lt;span&gt;ToList&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;options&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ChatOptions&lt;/span&gt;&lt;span&gt; { Tools &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; tools };&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;response&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; chatClient.&lt;/span&gt;&lt;span&gt;GetResponseAsync&lt;/span&gt;&lt;span&gt;(messages, options);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;conditional-generation&quot;&gt;Conditional Generation&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The AI tool code is only generated when &lt;code dir=&quot;auto&quot;&gt;Microsoft.Extensions.AI&lt;/code&gt; is referenced in your project. If you don’t reference it, the &lt;code dir=&quot;auto&quot;&gt;[Tool]&lt;/code&gt; attribute still compiles (it’s just an attribute), but no &lt;code dir=&quot;auto&quot;&gt;AIFunction&lt;/code&gt; classes or registration code are emitted. This means existing projects that add the DI package won’t get unexpected dependencies.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;aot-safe-argument-extraction&quot;&gt;AOT-Safe Argument Extraction&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The generated &lt;code dir=&quot;auto&quot;&gt;InvokeCoreAsync&lt;/code&gt; handles the &lt;code dir=&quot;auto&quot;&gt;JsonElement&lt;/code&gt;-vs-already-deserialized argument problem that trips up most hand-written AI tools. For every standard type, the generator emits a direct &lt;code dir=&quot;auto&quot;&gt;JsonElement&lt;/code&gt; accessor:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Extraction&lt;/th&gt;
&lt;th&gt;Reflection-free&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;string&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;GetString()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;int&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;long&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;short&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;byte&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;GetInt32()&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;GetInt64()&lt;/code&gt;, etc.&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;bool&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;GetBoolean()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;double&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;float&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;decimal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;GetDouble()&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;GetSingle()&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;GetDecimal()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Guid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;GetGuid()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;DateTime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;GetDateTime()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;DateTimeOffset&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;GetDateTimeOffset()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;DateOnly&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;TimeOnly&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;TimeSpan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Parse(GetString())&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enums&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Enum.Parse&amp;#x3C;T&gt;(GetString())&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complex types&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;JsonSerializer.Deserialize&amp;#x3C;T&gt;()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Needs &lt;code dir=&quot;auto&quot;&gt;JsonSerializerContext&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;If the argument arrives as a &lt;code dir=&quot;auto&quot;&gt;JsonElement&lt;/code&gt; (common when the framework hasn’t pre-deserialized), the correct accessor is used. If it arrives already typed (some frameworks do this), a direct cast is used. Both paths are handled with a single &lt;code dir=&quot;auto&quot;&gt;is JsonElement&lt;/code&gt; check — no try/catch, no &lt;code dir=&quot;auto&quot;&gt;Convert.ChangeType&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;cancellationtoken-handling&quot;&gt;CancellationToken Handling&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;If your service method accepts a &lt;code dir=&quot;auto&quot;&gt;CancellationToken&lt;/code&gt;, the generator does the right thing automatically:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;Description&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Searches products&quot;&lt;/span&gt;&lt;span&gt;)]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;List&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Product&lt;/span&gt;&lt;span&gt;&gt;&gt; &lt;/span&gt;&lt;span&gt;SearchAsync&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;Description&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Search query&quot;&lt;/span&gt;&lt;span&gt;)] &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;query&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;CancellationToken&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;cancellationToken&lt;/span&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;// not exposed as a tool parameter&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;CancellationToken&lt;/code&gt; is excluded from the tool’s parameter metadata and properties. In &lt;code dir=&quot;auto&quot;&gt;InvokeCoreAsync&lt;/code&gt;, it’s passed through from the framework’s cancellation token — not extracted from the argument dictionary.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;methods-without-description-are-skipped&quot;&gt;Methods Without &lt;code dir=&quot;auto&quot;&gt;[Description]&lt;/code&gt; Are Skipped&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Only methods with &lt;code dir=&quot;auto&quot;&gt;[Description]&lt;/code&gt; become tools. This gives you fine-grained control over what’s exposed to the LLM. Internal methods, admin operations, or anything you don’t want an AI agent calling — just don’t add the attribute.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;works-with-your-existing-di-setup&quot;&gt;Works With Your Existing DI Setup&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;[Tool]&lt;/code&gt; attribute goes on interfaces, while &lt;code dir=&quot;auto&quot;&gt;[Singleton]&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;[Scoped]&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;[Transient]&lt;/code&gt; go on implementation classes — same as before. You keep using &lt;code dir=&quot;auto&quot;&gt;AddGeneratedServices()&lt;/code&gt; for your service registrations and add &lt;code dir=&quot;auto&quot;&gt;AddGeneratedAITools()&lt;/code&gt; alongside it:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;services.&lt;/span&gt;&lt;span&gt;AddGeneratedServices&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;services.&lt;/span&gt;&lt;span&gt;AddGeneratedAITools&lt;/span&gt;&lt;span&gt;();  &lt;/span&gt;&lt;span&gt;// only if M.E.AI is referenced&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The two generators are independent. AI tool generation doesn’t affect or depend on your service registrations.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;getting-started&quot;&gt;Getting Started&lt;/h2&gt;&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;Add &lt;code dir=&quot;auto&quot;&gt;[Tool]&lt;/code&gt; to the interface&lt;/li&gt;
&lt;li&gt;Add &lt;code dir=&quot;auto&quot;&gt;[Description]&lt;/code&gt; to the interface and the methods you want exposed&lt;/li&gt;
&lt;li&gt;Add &lt;code dir=&quot;auto&quot;&gt;[Description]&lt;/code&gt; to parameters (optional but recommended — it helps the LLM)&lt;/li&gt;
&lt;li&gt;Reference &lt;code dir=&quot;auto&quot;&gt;Microsoft.Extensions.AI&lt;/code&gt; in your project&lt;/li&gt;
&lt;li&gt;Call &lt;code dir=&quot;auto&quot;&gt;services.AddGeneratedAITools()&lt;/code&gt; at startup&lt;/li&gt;
&lt;li&gt;Resolve &lt;code dir=&quot;auto&quot;&gt;IEnumerable&amp;#x3C;AITool&gt;&lt;/code&gt; and pass to your chat client&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Check the &lt;a href=&quot;https://www.shinylib.net/di/&quot;&gt;DI documentation&lt;/a&gt; for the full setup guide and the &lt;a href=&quot;https://www.shinylib.net/di/release-notes/&quot;&gt;release notes&lt;/a&gt; for the complete changelog.&lt;/p&gt;</content:encoded><category>Release</category><category>AI</category><category>DI</category></item><item><title>One Contract, Three Transports — Mediator AI Tooling</title><link>https://www.shinylib.net/blog/2026/04/mediator-ai-tools/</link><guid isPermaLink="true">https://www.shinylib.net/blog/2026/04/mediator-ai-tools/</guid><pubDate>Tue, 28 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;What if you could write a single C# record and have it automatically become a fully typed AI tool — with zero adapter code? That’s what Shiny Mediator 6.3 delivers.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-problem&quot;&gt;The Problem&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Building AI tool calling today means writing repetitive adapter code. You define a JSON schema by hand, parse arguments from the LLM response, validate them, call your business logic, and serialize the result back. If you already have a mediator contract for the same operation, you’re duplicating intent across two representations. Multiply that by every tool your agent needs — ten, twenty, fifty tools — and it becomes a real maintenance problem.&lt;/p&gt;
&lt;p&gt;Worse, the schema and the code drift apart. You rename a property in your contract but forget to update the JSON schema. You add a new required parameter but the tool adapter still treats it as optional. The LLM hallucinates a parameter name that &lt;em&gt;used to&lt;/em&gt; exist, and your hand-written parser silently swallows the error. These bugs are subtle, hard to test, and only surface at runtime.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-contract-first-approach&quot;&gt;The Contract-First Approach&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;In Shiny Mediator, a &lt;strong&gt;contract&lt;/strong&gt; is a plain record that describes an operation:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;Description&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Get the current weather forecast for a given city&quot;&lt;/span&gt;&lt;span&gt;)]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;record&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;GetWeather&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;property&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;Description&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;The city name to get weather for&quot;&lt;/span&gt;&lt;span&gt;)]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;City&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;property&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;Description&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;Temperature unit: &apos;celsius&apos; or &apos;fahrenheit&apos;&quot;&lt;/span&gt;&lt;span&gt;)]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Unit&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;celsius&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;) : &lt;/span&gt;&lt;span&gt;IRequest&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;WeatherResult&lt;/span&gt;&lt;span&gt;&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;record&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;WeatherResult&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;City&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;double&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Temperature&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Unit&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Condition&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;And a &lt;strong&gt;handler&lt;/strong&gt; implements the logic:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;MediatorSingleton&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;partial&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;GetWeatherHandler&lt;/span&gt;&lt;span&gt; : &lt;/span&gt;&lt;span&gt;IRequestHandler&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;GetWeather&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;WeatherResult&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Task&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;WeatherResult&lt;/span&gt;&lt;span&gt;&gt; &lt;/span&gt;&lt;span&gt;Handle&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;GetWeather&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;request&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;IMediatorContext&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;context&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;CancellationToken&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ct&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;// your logic here&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s the only code you write. From here, source generators take over.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;ai-tool-generation&quot;&gt;AI Tool Generation&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Add a &lt;code dir=&quot;auto&quot;&gt;[Description]&lt;/code&gt; attribute to your contract and set &lt;code dir=&quot;auto&quot;&gt;ShinyMediatorGenerateAITools=true&lt;/code&gt; in your project:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;PropertyGroup&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;ShinyMediatorGenerateAITools&lt;/span&gt;&lt;span&gt;&gt;true&amp;#x3C;/&lt;/span&gt;&lt;span&gt;ShinyMediatorGenerateAITools&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;PropertyGroup&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The source generator produces a fully typed &lt;code dir=&quot;auto&quot;&gt;AIFunction&lt;/code&gt; subclass compatible with &lt;code dir=&quot;auto&quot;&gt;Microsoft.Extensions.AI&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// auto-generated&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;internal&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sealed&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;class&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;GetWeatherAIFunction&lt;/span&gt;&lt;span&gt; : &lt;/span&gt;&lt;span&gt;AIFunction&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;private&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;readonly&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;IMediator&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;_mediator&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;private&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;static&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;readonly&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;JsonElement&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;_jsonSchema&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;JsonDocument.&lt;/span&gt;&lt;span&gt;Parse&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&quot;&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&quot;type&quot;: &quot;object&quot;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&quot;properties&quot;: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&quot;city&quot;: { &quot;description&quot;: &quot;The city name to get weather for&quot;, &quot;type&quot;: &quot;string&quot; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&quot;unit&quot;: { &quot;description&quot;: &quot;Temperature unit&quot;, &quot;type&quot;: &quot;string&quot;, &quot;default&quot;: &quot;celsius&quot; }&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&quot;required&quot;: [&quot;city&quot;]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;&quot;&quot;&quot;&lt;/span&gt;&lt;span&gt;).RootElement.&lt;/span&gt;&lt;span&gt;Clone&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;override&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Name&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;GetWeather&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;override&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Description&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;Get the current weather forecast for a given city&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;public&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;override&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;JsonElement&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;JsonSchema&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; _jsonSchema;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;protected&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;override&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ValueTask&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;object&lt;/span&gt;&lt;span&gt;?&gt; &lt;/span&gt;&lt;span&gt;InvokeCoreAsync&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;AIFunctionArguments&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;arguments&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;CancellationToken&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;cancellationToken&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;json&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; JsonSerializer.&lt;/span&gt;&lt;span&gt;SerializeToElement&lt;/span&gt;&lt;span&gt;(arguments);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;contract&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;GetWeather&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;City&lt;/span&gt;&lt;span&gt;: json.&lt;/span&gt;&lt;span&gt;GetProperty&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;city&quot;&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;GetString&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;            &lt;/span&gt;&lt;span&gt;Unit&lt;/span&gt;&lt;span&gt;: json.&lt;/span&gt;&lt;span&gt;TryGetProperty&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;unit&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;out&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;u&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span&gt; u.ValueKind &lt;/span&gt;&lt;span&gt;!=&lt;/span&gt;&lt;span&gt; JsonValueKind.Null&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;                &lt;/span&gt;&lt;span&gt;?&lt;/span&gt;&lt;span&gt; u.&lt;/span&gt;&lt;span&gt;GetString&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;celsius&quot;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;        &lt;/span&gt;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;_&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;result&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; _mediator.&lt;/span&gt;&lt;span&gt;Request&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;WeatherResult&lt;/span&gt;&lt;span&gt;&gt;(contract, cancellationToken);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; result;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;A registration extension is also generated:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;builder.Services.&lt;/span&gt;&lt;span&gt;AddShinyMediator&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;x&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; x&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddMediatorRegistry&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;AddGeneratedAITools&lt;/span&gt;&lt;span&gt;()   &lt;/span&gt;&lt;span&gt;// registers every [Description] contract as an AITool&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Then pass the tools to any &lt;code dir=&quot;auto&quot;&gt;IChatClient&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre dir=&quot;ltr&quot;&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;tools&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; services.&lt;/span&gt;&lt;span&gt;GetServices&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;AITool&lt;/span&gt;&lt;span&gt;&gt;().&lt;/span&gt;&lt;span&gt;ToList&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;options&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;ChatOptions&lt;/span&gt;&lt;span&gt; { Tools &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; tools };&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;response&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; chatClient.&lt;/span&gt;&lt;span&gt;GetResponseAsync&lt;/span&gt;&lt;span&gt;(history, options);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;middleware-runs-on-ai-tool-calls-too&quot;&gt;Middleware Runs on AI Tool Calls Too&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Because the generated AI tools dispatch through the mediator pipeline, every middleware you’ve already configured applies to AI tool calls automatically. Logging, validation, authorization, exception handling, caching — all of it fires without any extra wiring.&lt;/p&gt;
&lt;p&gt;This is a significant advantage over hand-rolled &lt;code dir=&quot;auto&quot;&gt;AIFunction&lt;/code&gt; implementations. When you write a tool adapter manually, it typically calls your service layer directly, bypassing cross-cutting concerns. With the mediator approach, an AI tool call follows the same pipeline as a UI-triggered action or an API call. Your audit log captures it. Your validation middleware rejects bad input before the handler runs. Your error handling middleware catches exceptions and returns structured errors the LLM can interpret.&lt;/p&gt;
&lt;p&gt;You can even write middleware that targets AI calls specifically — for example, injecting a &lt;code dir=&quot;auto&quot;&gt;MediatorContext&lt;/code&gt; value that tells the handler the call originated from an LLM, so you can apply tighter authorization or rate limiting for AI-initiated operations.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;scaling-to-many-tools&quot;&gt;Scaling to Many Tools&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The real power shows when your agent needs many tools. Instead of maintaining dozens of &lt;code dir=&quot;auto&quot;&gt;AIFunction&lt;/code&gt; subclasses with hand-written schemas, you just add &lt;code dir=&quot;auto&quot;&gt;[Description]&lt;/code&gt; to your existing contracts. Every contract with a description attribute becomes a tool at the next build.&lt;/p&gt;
&lt;p&gt;Adding a new tool to your agent is the same workflow as adding any new mediator operation:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Define the contract record with &lt;code dir=&quot;auto&quot;&gt;[Description]&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Implement the handler&lt;/li&gt;
&lt;li&gt;Done — the tool is registered automatically&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;No schema files to maintain. No adapter classes to write. No registration code to update. The source generator handles the JSON schema, argument parsing, DI wiring, and &lt;code dir=&quot;auto&quot;&gt;AIFunction&lt;/code&gt; implementation.&lt;/p&gt;
&lt;p&gt;This also means removing a tool is just deleting the &lt;code dir=&quot;auto&quot;&gt;[Description]&lt;/code&gt; attribute (or the contract itself). There are no orphaned adapters or stale schema definitions to clean up.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;beyond-ai-the-same-contract-powers-http-too&quot;&gt;Beyond AI: The Same Contract Powers HTTP Too&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The same contract-first approach extends beyond AI tooling. Shiny Mediator also generates HTTP clients and ASP.NET endpoints from your contracts — meaning a single record and handler can serve as an AI tool, a typed HTTP client, and a REST endpoint simultaneously. The transports are generated; you write the logic once.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;why-this-matters&quot;&gt;Why This Matters&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Traditional tool-calling setups require you to maintain parallel definitions:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Without Mediator&lt;/th&gt;
&lt;th&gt;With Mediator&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Business logic&lt;/td&gt;
&lt;td&gt;Handler class&lt;/td&gt;
&lt;td&gt;Handler class&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI tool schema&lt;/td&gt;
&lt;td&gt;Manual JSON schema&lt;/td&gt;
&lt;td&gt;Generated from contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI tool adapter&lt;/td&gt;
&lt;td&gt;Manual AIFunction subclass&lt;/td&gt;
&lt;td&gt;Generated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Argument parsing&lt;/td&gt;
&lt;td&gt;Manual deserialization&lt;/td&gt;
&lt;td&gt;Generated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DI registration&lt;/td&gt;
&lt;td&gt;Manual for each tool&lt;/td&gt;
&lt;td&gt;Generated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Middleware/validation&lt;/td&gt;
&lt;td&gt;Manual per tool&lt;/td&gt;
&lt;td&gt;Automatic via pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;With the contract-first approach, adding a new capability to your application — whether it’s exposed as an AI tool, an HTTP endpoint, or both — is one record and one handler.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;full-aot-compliance&quot;&gt;Full AOT Compliance&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The generated &lt;code dir=&quot;auto&quot;&gt;AIFunction&lt;/code&gt; classes are fully Native AOT compatible. Here’s what makes that possible:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No reflection.&lt;/strong&gt; The generator reads &lt;code dir=&quot;auto&quot;&gt;[Description]&lt;/code&gt; attributes, property types, nullability, and default values at compile time. It emits direct property access code — &lt;code dir=&quot;auto&quot;&gt;json.GetProperty(&quot;city&quot;).GetString()!&lt;/code&gt; — instead of relying on &lt;code dir=&quot;auto&quot;&gt;JsonSerializer.Deserialize&amp;#x3C;T&gt;()&lt;/code&gt; or reflection-based binding.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Static JSON schema.&lt;/strong&gt; The schema is a compile-time constant string parsed once into a &lt;code dir=&quot;auto&quot;&gt;JsonElement&lt;/code&gt; on first use. There’s no runtime schema construction, no &lt;code dir=&quot;auto&quot;&gt;JsonSerializerOptions&lt;/code&gt; configuration, and no dynamic type inspection.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Constructor-based hydration.&lt;/strong&gt; The generated code constructs the contract using its primary constructor with named arguments. No &lt;code dir=&quot;auto&quot;&gt;Activator.CreateInstance&lt;/code&gt;, no &lt;code dir=&quot;auto&quot;&gt;FormatterServices&lt;/code&gt;, no property setters via reflection.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Concrete types throughout.&lt;/strong&gt; Each generated class is a sealed, non-generic concrete type. The DI registrations are explicit &lt;code dir=&quot;auto&quot;&gt;AddSingleton&amp;#x3C;AITool&gt;(sp =&gt; new GetWeatherAIFunction(...))&lt;/code&gt; calls — no open generics or service descriptor scanning at runtime.&lt;/p&gt;
&lt;p&gt;This means your AI tools work in trimmed, ahead-of-time compiled applications — including .NET MAUI apps targeting iOS and Android — without linker warnings or runtime failures. The same tools that power your cloud API also run on-device in a fully native binary.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;supported-type-mappings&quot;&gt;Supported Type Mappings&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The generator handles the full range of C# types in your contracts:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;C# Type&lt;/th&gt;
&lt;th&gt;JSON Schema&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;string&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Guid&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;Uri&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;DateTime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;&quot;string&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;bool&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;&quot;boolean&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;int&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;long&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;short&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;byte&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;&quot;integer&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;float&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;double&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;decimal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;&quot;number&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;enum&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;&quot;string&quot;&lt;/code&gt; with &lt;code dir=&quot;auto&quot;&gt;&quot;enum&quot;&lt;/code&gt; array&lt;/td&gt;
&lt;td&gt;All values listed for the LLM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;T[]&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;IEnumerable&amp;#x3C;T&gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;&quot;array&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nullable types (&lt;code dir=&quot;auto&quot;&gt;T?&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Omitted from &lt;code dir=&quot;auto&quot;&gt;&quot;required&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default values&lt;/td&gt;
&lt;td&gt;Included as &lt;code dir=&quot;auto&quot;&gt;&quot;default&quot;&lt;/code&gt; in schema&lt;/td&gt;
&lt;td&gt;Fallback used when LLM omits the parameter&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;ICommand&lt;/code&gt; contracts are also supported — the generated tool returns a success message string instead of a typed result.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;getting-started&quot;&gt;Getting Started&lt;/h2&gt;&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;Add the &lt;code dir=&quot;auto&quot;&gt;[Description]&lt;/code&gt; attribute to your contracts and their properties&lt;/li&gt;
&lt;li&gt;Set &lt;code dir=&quot;auto&quot;&gt;&amp;#x3C;ShinyMediatorGenerateAITools&gt;true&amp;#x3C;/ShinyMediatorGenerateAITools&gt;&lt;/code&gt; in your project file&lt;/li&gt;
&lt;li&gt;Reference &lt;code dir=&quot;auto&quot;&gt;Microsoft.Extensions.AI&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Call &lt;code dir=&quot;auto&quot;&gt;.AddGeneratedAITools()&lt;/code&gt; during mediator setup&lt;/li&gt;
&lt;li&gt;Resolve &lt;code dir=&quot;auto&quot;&gt;IEnumerable&amp;#x3C;AITool&gt;&lt;/code&gt; from DI and pass to your chat client&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Every contract with a &lt;code dir=&quot;auto&quot;&gt;[Description]&lt;/code&gt; attribute automatically becomes a tool. Add a new contract, and the next build picks it up — no registration changes, no schema files, no adapter classes.&lt;/p&gt;
&lt;p&gt;Check out the &lt;a href=&quot;https://github.com/shinyorg/mediator/tree/main/samples/Sample.CopilotConsole&quot;&gt;Sample.CopilotConsole&lt;/a&gt; for a working example that wires up AI tools with a chat loop, or browse the &lt;a href=&quot;https://www.shinylib.net/mediator/&quot;&gt;Mediator documentation&lt;/a&gt; for the full setup guide.&lt;/p&gt;</content:encoded><category>Release</category><category>AI</category><category>Mediator</category></item></channel></rss>