Skip to content
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

DocumentDB Releases

Feature

Configurable write access for the admin assistant. The assistant is read-only by default, and that has not changed. What is new is a per-connection opt-in on the Assistant settings page - three checkboxes for insert_document, update_document and delete_document - that adds those tools to the chat opened against that connection. Writes are scoped to the connection they were opted in on: a chat opened against staging cannot reach through to production even if it knows the id. The underlying profile’s Read-only flag still refuses writes regardless, so a read-only profile stays read-only whatever the assistant is allowed. See the assistant page.

Fix

Admin TUI: the settings screen for the assistant no longer looks stuck after Save. It saved the settings but left the form open behind a green “Saved.” line, which read to more than one person as if the Save button had not fired. Save (and Save-and-test on success) now pop back to the connection with a toast, matching what the web front end does.

Fix

Admin TUI: the breadcrumb at the bottom of the shell now updates on every screen change. It was bound to Status alone, so popping into a screen whose status was also empty left the previous stack’s crumbs on screen. The status bar now also reads the navigation signal, so push, pop and Replace all refresh it.

Feature

The terminal admin tool as an Aspire resource — AddDocumentDbAdminTerminal. Aspire 13.5 added interactive terminal sessions (WithTerminal), which is the piece that was missing: the terminal front end can now be modelled in an AppHost as a process rather than a container, with every store you reference already connected.

#pragma warning disable ASPIRETERMINAL001
var store = builder.AddPostgres("pg").AddDatabase("ordersdb").AsDocumentStore("orders");
builder.AddDocumentDbAdminTerminal()
.WithReference(store)
.WithStartupProfile(store)
.WaitFor(store);
Terminal window
aspire config set features.terminalCommandsEnabled true
aspire terminal attach documentdb-terminal

WithReference is the contract that already existed — ConnectionStrings:{name} + Shiny:DocumentDb:{name}:Provider — so the tool itself needed no change; WithStartupProfile passes --profile, and attaching lands you on that database rather than the connection list. Also WithDataDirectory, WithReadOnly, WithoutAi, and DocumentDbAdminTerminalTool.Local for a repository that pins the tool in its local manifest instead of expecting a global install.

For a local AppHost this replaces the container front end outright, and it is the only one of the two that opens a file-backed 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 AddDocumentDbAdmin. The surface carries Aspire’s own ASPIRETERMINAL001 experimental diagnostic rather than a new one, so a single suppression covers both.

BREAKING

Shiny.DocumentDb.Aspire.Hosting now requires Aspire 13.5. The terminal resource is built on WithTerminal, which does not exist before 13.5, so the package’s floor moves with it. Nothing else in the integration changed; an AppHost on 13.4 or earlier needs the Aspire bump before taking this version.

Feature

Geofencing over document regions — Shiny.DocumentDb.Geofencing. Platform geofencing caps you at 20 regions on iOS and 60 on Android, and every one of them is a circle. This package removes both limits by pointing background GPS at documents you already store: register a spatially-mapped document type as a region set, and every reading runs one spatial query to work out which region the device is in.

builder.Services.AddDocumentGeofencing<MyGeofenceDelegate>(cfg => cfg
.AddRegionSet<Zone>("zones", z => z.Id, z => z.Name, filter: z => z.Active)
.AddRegionSet<Shop>("shops", s => s.Id, s => s.Name, withinMeters: 500)
);

Polygons (with holes) are monitored by containment; withinMeters: switches a set to proximity, which is what point documents — a store location, a city centre — need, since a GPS point never falls “inside” another point. filter: narrows which documents of the type count. Register several sets and each is tracked independently, so a device is inside one region of every set at once, and each raises its own DocumentRegionChange — exit first, then entry, when a boundary is crossed — carrying the region document, not just an identifier.

The current region per set is persisted, so an app the OS kills and relaunches resumes where it left off instead of replaying entries, and an exit raised after the relaunch still describes the region it left. IDocumentGeofenceManager covers permissions, start/stop, and an on-demand GetCurrent().

Because the regions are ordinary documents, they can live wherever the rest of your data does — an on-device SQLite file, or a shared server database where regions are managed centrally. The reference geo dataset works as a region source with no extra wiring. See Geofencing.

iOS, Android, and Mac Catalyst only — the package rides Shiny.Locations background GPS. Providers without spatial support (LiteDB, IndexedDB, Azure Table, DynamoDB) throw from Start().

samples/Sample.Maui gains a Geofences tab demonstrating the whole surface — start/stop, a live transition log, GetCurrent(), and the region documents — under the sample’s AOT-strict settings.

Feature

A public host surface — Shiny.DocumentDb.Hosting. Hosting DocumentDb over a transport needed three internal types, so Shiny.DocumentDb.AspNetCore and Shiny.DocumentDb.Extensions.AI were named in this package’s InternalsVisibleTo list, and so was the first host written outside this repo. That does not scale — every new host, in any repo, by anyone, needed another entry and a DocumentDb release to go with it — and it was a strange contract: the API those hosts depended on was unversioned and undocumented, and could change in a patch with nothing failing until runtime.

Those three things are now public, as DocumentPredicate.Compile<T>, DocumentFilter.Parse<T> / ParseJson, and DocumentStoreAccessor.GetMappings / GetVersionMapping. Each is a thin facade over the implementation that was already there — no behaviour changed, and the three InternalsVisibleTo entries for host packages are gone. A host package can now live in any repo without a release here first.

DocumentPredicate.Compile is deliberately not named after the interpreter behind it: what a caller gets is a delegate, and the contract is that producing it never uses Reflection.Emit. That is why a host calls it instead of Expression.Compile() — a scope has to be checked against an incoming document on POST/PUT, before there is anything to query against, and generating IL at runtime would forfeit the trimmed/AOT guarantee of the app doing the hosting. It is also the same evaluator the query layer uses, so a host cannot end up with its pushed-down predicate and its in-memory check disagreeing about what a scope means. The promise is enforced by a Native AOT publish rather than a comment, and a public-API surface test pins every signature so a refactor cannot change them quietly.

GetMappings works on every provider, not just the relational ones — the previous internal route (IQueryExecutor.Options) existed only on the relational store, and the documented fallback silently turned ETags off when it missed. Provider InternalsVisibleTo grants are unaffected: a provider implements the engine rather than hosting it.

Feature

Orleans persistent stream provider. siloBuilder.AddDocumentDbStreams("Default", …) gives an Orleans cluster durable, inspectable streams on the database it already uses for membership, grain storage and reminders — no queue service to run, and a backlog you can actually look at in ShinyDocDbMyAdmin when a queue will not drain. Reads take no locks and write nothing per message (Orleans guarantees one pulling agent per queue, so the receiver works from a cursor), an adaptive backoff keeps an idle cluster from polling ten times a second per queue, and where the backend has a native change feed one subscription per silo wakes every receiver the moment anything is enqueued — including from another silo.

Sequencing deliberately does not use a native identity column: those hand out values at insert time while 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.

Durable rewind. IsRewindable is true: a subscriber can resume from a StreamSequenceToken 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 this — behind Azure Queue or SQS the message is gone once handed over. The rewind window is exactly Retention, and replay reads through a second (QueueId, StreamId, Seq) index so it reads one stream’s history rather than scanning the queue.

Operations. IStreamAdmin (keyed by provider name) reports per-queue depth, lag, retained history and which streams are not draining, and ShinyDocDbMyAdmin gains a Streams screen answering the same questions in both the web and terminal front ends. Both are read-only by design: a stream event is a position in a gap-free sequence that every subscriber holds a cursor into, so unlike an outbox message there is no safe operator action on one. Delivery cursors are also persisted per queue, so a queue retaining history for rewind does not replay all of it on restart.

Backends: PostgreSQL, SQL Server, MySQL, MariaDB, Oracle, CockroachDB. SQLite, LiteDB, DuckDB and the document/key-partitioned stores are refused at silo start — the gate is the new SupportsPessimisticLocking capability, not a hard-coded list. CockroachDB’s SERIALIZABLE transactions can abort a contended enqueue with a retryable 40001; the provider retries those automatically. This is a database-backed queue: expect thousands of events/sec on PostgreSQL, not the hundreds of thousands an event-streaming platform delivers.

Enhancement

Sharing a table now fails with an answer, not just a refusal. Pointing two document types at the same cfg.Table has always thrown — a custom table is exclusive to one type by design — but the message only said 'x' is already mapped to another type, which reads as a library bug and pushes you toward the wrong fix (renaming one of them). It now states the rule and names the thing that actually delivers co-location: leave cfg.Table unset on all of them and set DocumentStoreOptions.TableName, which puts every type in one table discriminated by TypeName. No behaviour change.

Enhancement

LockMode now takes a real row lock. It shipped validated but inert — the API required an active transaction and then issued an ordinary read, so session.Get(id, LockMode.Update) blocked nothing on any backend. It now emits the engine’s own locking syntax: FOR UPDATE / FOR SHARE on PostgreSQL, MySQL and CockroachDB, LOCK IN SHARE MODE for MariaDB’s shared lock (MariaDB never adopted FOR SHARE), FOR UPDATE on Oracle, and the WITH (UPDLOCK, HOLDLOCK) table hint on SQL Server. Oracle throws for LockMode.Share rather than degrading to an unlocked read — it has no shared row lock. SQLite and DuckDB are unchanged and correct as they were: an explicit write transaction already locks the whole database.

The new IDocumentStore.SupportsPessimisticLocking reports whether a backend takes a real row lock, so anything that reserves values from a shared row — counters, sequences, leases — can check rather than assume. Two new IDatabaseProvider hooks (BuildLockClause, BuildLockTableHint) are defaulted, so custom providers need no changes.

Fix

The admin tool’s delete now clears the spatial sidecar. Deleting a document through ShinyDocDbMyAdmin removed its blob and vector rows but left its spatial index row behind, and Clear left both blobs and spatial rows behind for the whole type. Nothing in the database raises on that — the sidecars carry no foreign key — so the result was a bounding box that resolved to a document that no longer existed. Both paths now clean every sidecar, using the provider’s own spatial statements (which on SQLite means the R*Tree row and the map row that gives it a rowid). Full-text indexes are unaffected: the database maintains those.

Enhancement

Deleting a document from the browser asks first. The per-row Delete button in the web UI deleted on a single click with no confirmation. It now opens a dialog that names what the delete takes with it — blob payloads, vector embedding, spatial index row — and says when a temporal type will gain a Removed version instead. The terminal UI already confirmed; its wording now lists the same sidecars.

Feature

PortableSpatial is reachable from the Aspire client. The dependency-free spatial envelope tier was only settable by constructing a provider yourself, so anyone wiring a store through builder.AddDocumentStore(...) was locked into the native tier — and on PostgreSQL that means PostGIS, which the stock postgres image doesn’t ship. builder.AddDocumentStore("orders", settings => settings.PortableSpatial = true) now forces the envelope tier, and the setting flows to the PostgreSQL, CockroachDB and SQL Server providers.

Enhancement

A missing spatial extension reports the remedy. Provisioning the native tier used to fail with a raw driver error — extension "postgis" is not available, thrown from deep inside the first spatial write with no hint that PostGIS was optional. Spatial table creation now surfaces a DocumentConfigurationException naming the fix (PostGIS-enabled image, CREATE EXTENSION grant, or PortableSpatial = true), keeping the driver error as the inner exception. Applies to PostgreSQL and DuckDB; unrelated failures at the same statement still surface unchanged.

Feature

The Admin UI is a Docker Desktop extension. docker extension install aritchie/shiny-docdb-myadmin-extension adds a tab that starts the admin container, waits for it, and opens it — and hands it every database container already running on your machine, connected. PostgreSQL (including PostGIS, pgvector and TimescaleDB), MySQL, MariaDB, SQL Server, Oracle Free/XE and CockroachDB are discovered by image; the credentials come out of each container’s own environment, and the connection is passed as the same ConnectionStrings__{name} / Shiny__DocumentDb__{name}__Provider pair the Aspire integration emits — so it arrives under the from host badge, exactly like one an AppHost referenced in. Addressing goes over the Docker network rather than a published port, so a database that never published one still works.

The app renders inside the tab, framed over its published port — an ordinary same-machine request, so Blazor Server’s SignalR circuit is an ordinary WebSocket rather than anything routed through the extension’s socket proxy. The container it starts is an ordinary one: it appears in your Containers list, and uninstalling the extension leaves it and its data volume alone.

Marketplace submissions are paused while Docker reviews Marketplace security, so until that reopens Docker Desktop needs Settings → Extensions → “Allow only extensions distributed through the Docker Marketplace” turned off before it will install.

Enhancement

ShinyDocDbMyAdmin__FrameAncestors lets the admin UI be embedded. Blazor’s interactive server render mode blocks framing by default — Content-Security-Policy: frame-ancestors 'self' plus X-Frame-Options: SAMEORIGIN — which is right for a tool that can read and write your databases, but also means nothing can host the UI in a frame. Setting this variable uses its value as the frame-ancestors policy verbatim — or any / * for every embedder and drops the X-Frame-Options header alongside it. Unset, nothing changes. The Docker Desktop extension sets it to * on the container it creates; leave it unset for anything reachable beyond your own machine.

Enhancement

The admin image is mirrored to Docker Hub as aritchie/shiny-docdb-myadmin, alongside the existing ghcr.io/shinyorg/shiny-docdb-myadmin. Not a second build: one push writes the same digests to both repositories, and each registry’s :latest, :<version>, :demo and :demo-<version> are manifest lists over those same blobs, so the two can never disagree about what a version contains. GHCR stays canonical — AddDocumentDbAdmin is unchanged and still pulls from there.

Fix

Shiny.DocumentDb.Sqlite no longer trips the NuGet audit. Microsoft.Data.Sqlite still brings the 2.1.x SQLitePCLRaw bundle, whose native SQLitePCLRaw.lib.e_sqlite3 carries a high-severity advisory (GHSA-2m69-gcr7-jv3q) — so every consumer of the SQLite provider saw an NU1903 warning on restore. The package now references SQLitePCLRaw.bundle_e_sqlite3 3.x directly, which lifts the whole SQLitePCLRaw graph onto a patched SQLite build. No API or behaviour change. Shiny.DocumentDb.Sqlite.SqlCipher keeps its own bundle_e_sqlcipher and is unchanged.

Feature

The Admin UIs speak field-level encryption. Encrypted fields shipped in this release; the admin tools knew nothing about them. Now they read the envelope, describe it, and refuse to quietly destroy it — all without any key.

  • Recognised everywhere. A enc:1:k1:… value renders as 🔒 encrypted · key k1 in the browse grid and the JSON tree (with a show ciphertext 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 encrypted, not string.
  • Key coverage across a whole type. A new Encryption card counts, on demand, how many values sit under each key id, how many are still plaintext, and how many are under a key the sample never saw — answering the one question RewrapAsync<T>() cannot: did it finish? Retiring a key early makes documents unreadable, and nothing else tells you. Mode is reported as deterministic (observed) only when a repeated ciphertext proves it, and never as “randomized”, which is unprovable.
  • A downgrade guard on every write. SaveDocument now diffs the submitted body against the stored one and throws when a path that held an envelope would be saved in clear text, unless the caller passes allowEncryptionDowngrade: true. Both front ends turn that into a named confirmation. The failure this prevents is not an exception — the library reads a non-envelope as pre-encryption plaintext — so it was previously a silent loss of protection. Imports count and report the same thing rather than failing the file; exports say plainly that they carry ciphertext.
  • Query surfaces that cannot work are withdrawn or explained. Quick search and the default grid columns skip encrypted paths (still selectable); the filter console warns per mode; index creation warns without blocking.
  • Optionally, reading the values. A connection can carry a read-only key ring — masked until revealed, never in a grid, never in an export, never in demo mode, and never in the AI assistant, which reads only what is stored.

New public API in the core package: DocumentEncryptionFormat / EncryptedValueInfo — the envelope as a read-only contract, for any tooling that inspects stored documents without the key ring. Additive; the internal EncryptionEnvelope now forwards to it so there is one implementation of the shape test. See Encrypted fields.

Feature

REST + live-query endpoints (Shiny.DocumentDb.AspNetCore). A document type becomes a complete HTTP resource in one line — 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.

app.MapDocuments<Order>("/orders", o =>
{
o.Operations = DocumentEndpoints.All;
o.AllowFilterOn(x => x.Status, x => x.Total);
o.TypeInfo = AppJsonContext.Default.Order;
o.Scope<ITenantContext>((tenant, _) => x => x.TenantId == tenant.TenantId);
})
.RequireAuthorization("orders");

Filtering uses the store’s own string grammar behind a per-endpoint field allowlist (an unlisted field is a 400, not a table scan); take is clamped to MaxPageSize; cursor paging, sparse fieldsets, ETag/If-Match concurrency and ProblemDetails errors are all in. Scope(...) is the HTTP twin of the AI tools’ non-removable Where — resolved per request from the request’s DI scope, AND-ed, and out-of-scope documents are 404 rather than 403. MapDocumentCollection does the same for a schema-free JSON collection. New sample: samples/Sample.RestApi, over the same data as the OData sample. See REST & Live Queries.

Feature

MCP server (Shiny.DocumentDb.Mcp + the ShinyDocDbMcp dotnet tool). Point Claude Code, Claude Desktop, Copilot or any MCP client at a store and let it explore and query the data. The tools are the same Extensions.AI tools — one implementation, one security model — plus resources (documentdb://types, .../schema, .../sample, documentdb://stats), two prompts, and an audit line per call.

Terminal window
shiny-documentdb-mcp --provider sqlite --connection "Data Source=app.db"
builder.Services.AddDocumentDbMcpServer(mcp => { … }).WithHttpTransport();
app.MapDocumentDbMcp("/mcp").RequireAuthorization("mcp");

Read-only by default and writes need two locks (the per-type capability and AllowWrites()), page caps, property hiding, no raw-SQL tool and no schema mutation. The stdio tool discovers what to expose from the stored TypeName discriminators, so it needs no compiled document classes, and takes its connections from the admin tool’s existing profile store. New sample: samples/Sample.McpServer, over the same data as the OData and REST samples. See MCP Server.

Feature

Request-resolved AI tool filters. The non-removable per-type Where scope now has a form resolved on every tool call from the call’s own services — the answer to “which rows may this caller see” usually lives in a request-scoped service, not in a value fixed at registration.

t.Where(o => o.TenantId == "acme") // static, as before
.Where<ITenantContext>((tenant, _) => o => o.TenantId == tenant.TenantId) // resolved per call
.Where<IPermissionService>(async (perms, ctx) => …); // async form

It fails closed: a filter that throws, a service that will not resolve, or a call with no AIFunctionArguments.Services fails the tool call rather than running the query unscoped, and AddDocumentStoreAITools asserts at startup that each Where<TService> service is registered. Static-only registrations pay nothing. The MCP server supplies the per-request provider automatically; the IChatClient lane sets Services on the arguments. Collections get the same overloads with the string grammar.

Feature

Tenant-per-database routing, hardened. AddMultiTenantDocumentStore now survives a real deployment: a bounded store cache (LRU + idle eviction, with lease-based deferred disposal so eviction never pulls a store out from under a running request), a new overload taking a built store so tenants can live on any provider rather than relational only, per-tenant initialization, and IDocumentSession / IDocumentSessionFactory wired to the current tenant (they were not registered at all before).

services.AddMultiTenantDocumentStore(
tenantId => new MongoDbDocumentStore(MongoOptionsFor(tenantId)), // any provider
o =>
{
o.MaxCachedStores = 250;
o.IdleTimeout = TimeSpan.FromMinutes(30);
o.SeedFromRegisteredSeeders(); // startup seeders now run per tenant, on first touch
});

Each tenant’s store tags its telemetry with db.namespace = the tenant id (bucket it with StoreNameFactory at high tenant counts), and the new ITenantStoreManager adds ActiveTenants, WarmAsync and EvictAsync for onboarding/offboarding without a restart. See Multi-Tenancy.

Fix

Tenant stores were closed by the first request that finished. The scoped IDocumentStore registration handed the cached store to the DI scope, and the container disposes any IDisposable a scoped factory returns — so a tenant’s shared store was disposed at the end of the first request that touched it. The cache now owns store lifetime; the scope owns a lease.

BREAKING

Tenant stores can be disposed while the process lives. Idle/LRU eviction means a captured IDocumentStore from a tenant-routed registration may become disposed — resolve it per scope instead. Opt out with IdleTimeout = null and MaxCachedStores = int.MaxValue. The internal MultiTenantDocumentStoreFactory is deleted (it was never public); the existing AddMultiTenantDocumentStore(Func<string, DocumentStoreOptions>) call site is unchanged.

Feature

Transactional outbox. Record “this happened” in the same transaction as the write that made it happen, and have it delivered to a bus, an HTTP endpoint or an in-process mediator — with no second datastore and no dual-write window.

o.AddOutbox(); // maps OutboxMessage → its own "outbox" table
services.AddDocumentOutbox<BusDispatcher>(); // the processor + IOutboxAdmin
await using var session = store.OpenSession();
session.Add(order).Enqueue(new OrderPlaced(order.Id, order.Total));
await session.SaveChanges(); // both rows commit together, or neither does

Or declaratively, per type: cfg.PublishToOutbox(ctx => new OrderChanged(...)).

Delivery is at-least-once with an attempt counter, exponential backoff and dead-lettering; claiming is per-message optimistic concurrency, so any number of workers scale by just running. store.WatchOutbox(…) is a read-only stream for dashboards and health checks, IOutboxAdmin is the in-process operational view, and the traceparent captured at enqueue is restored at dispatch so a consumer’s span links back to the request that caused it. New span outbox.dispatch plus db.client.outbox.* counters and a pending-depth gauge.

Provider tier: relational (SQLite, SQLCipher, PostgreSQL, CockroachDB, SQL Server, MySQL, MariaDB, Oracle, DuckDB) and LiteDB. The rest implement a unit of work by compensation, which does nothing for a process that dies mid-unit — the exact window an outbox exists to close — so a new IDocumentStore.SupportsTransactions capability gates them out at host startup, by name, instead of shipping a promise that does not hold. Cosmos DB is a further no: “same transaction” there means “same logical partition”, and the store partitions by type name. Point those backends at IChangeFeedDocumentStore instead.

See Transactional Outbox.

Feature

Outbox queue screen in ShinyDocDbMyAdmin, in both the web and terminal front ends. Health strip (pending / scheduled / dead-lettered / processed, plus oldest pending — the number that separates “busy” from “the processor died”), state filters, dead letters grouped by message type and error, and requeue / purge behind a confirm. It cannot dispatch, and says so: delivery is your application’s IOutboxDispatcher, over a transport the tool cannot reach.

The assistant gets one new read-only tool, outbox_status. Requeue stays a deliberate human click. See Admin: Outbox.

Fix

SQL Server: ExecuteUpdate and SetProperty rejected date and Guid values. JSON_MODIFY only accepts the scalar types it can map onto a JSON value, so a datetimeoffset, date or uniqueidentifier parameter failed outright with “invalid for argument 3 of json_modify”. Those types are now bound as the exact text a normal document write would have serialized — which is also the form the ISO-8601 comparisons behind date predicates need in order to match.

Fix

In-memory query evaluation could not order dates, strings or Guids. The interpreter behind LiteDB and the other client-side query paths coerced both sides of a relational comparison to double, so x.CreatedAt > cutoff threw InvalidCastException. Non-numeric ordered values now compare through IComparable (with DateTime/DateTimeOffset normalized to UTC), and a comparison involving null is false rather than a coercion of zero — matching the SQL the relational providers emit for the same predicate.

Fix

Terminal admin: the connection form’s Provider dropdown showed a raw record dump. The select was built over ProviderDescriptor values rather than their names, so the closed dropdown rendered ProviderDescriptor { Kind = Sqlite, DisplayName = SQLite, Badge = sqlite, ConnectionStringT… instead of SQLite. It now shows the provider’s display name, on both a new connection and one opened for editing.

Enhancement

New :datetimeoffset type hint in the string query grammar, alongside :date. Schema-free dates are compared as ISO-8601 text, so the argument has to be written the way the document body was: a DateTime renders …Z (or no suffix), a DateTimeOffset renders …+00:00, and comparing one against the other gives wrong answers around fractional seconds. Hint whichever the stored property actually is.

BREAKING

Encrypted properties now leave the library as plaintext, not as re-encrypted envelopes. The encrypting converters are symmetric — reading a stored body decrypts, writing encrypts — so any code that materialized a document and serialized it again through the store’s own JsonSerializerOptions silently re-encrypted what it had just decrypted. Three places did: OData responses, the AI query/get/insert tool results, and GetDiff/GetDiffBetween. All now serialize through a plaintext writer.

GetDiff was outright broken by this: it compared the stored envelope against a freshly-encrypted one, and under EncryptionMode.Randomized the same plaintext encrypts differently every time, so every mapped property was reported as changed on every call. It now compares the decrypted documents.

Check your exposed entity sets before upgrading. A property that previously went over the wire as an opaque enc:1:… string now goes over it as its value. That is consistent with Get<T>(), ToList() and every other read API — encryption is at rest — but it is a disclosure change if you were relying on the old behavior. Project to a DTO, or stop exposing the type, if a property must not leave the process.

New public helper for anyone serializing documents themselves: DocumentEncryption.PlaintextView(options) / PlaintextView(typeInfo) returns the same instance when nothing is encrypted, so it is free to use unconditionally. See Encryption.

BREAKING

Per-type configuration is one ConfigureDocument<T> block — the flat per-type mapping methods are removed from every options class. The document type is named once, and its whole configuration reads top to bottom:

options.ConfigureDocument<Patient>(cfg =>
{
cfg.Table = "Patients";
cfg.MapIdProperty(x => x.Id);
cfg.AddSoftDelete(x => x.IsDeleted);
cfg.AddQueryFilter(u => !u.IsDeleted);
cfg.MapSpatialProperty(r => r.Location);
cfg.MapProperty(x => x.Ssn, p => p.Encrypt(EncryptionMode.Deterministic));
cfg.MapVectorProperty(d => d.Embedding, dimensions: 1536);
cfg.MapTemporal(o => o.Retention = TimeSpan.FromDays(90));
});

MapTypeToTable / MapTypeToCollection / MapTypeToContainer / MapTypeToStore / MapTypeToPartition, MapIdProperty, MapVersionProperty, AddQueryFilter, AddSoftDelete, MapSpatialProperty, MapVectorProperty, MapFullTextProperty, MapComputedProperty, MapBlob, MapBlobCollection, MapTemporal, MapIndexedProperty, MapEncryptedProperty, MapJsonSchema, OnBeforeWrite and OnAfterWrite all move onto the builder. Store-level configuration is untouched. The builder is written once against IDocumentStoreOptions, so every provider gets the same surface; provider packages add their own vocabulary over it (cfg.ToContainer on Cosmos, cfg.ToCollection on MongoDB/LiteDB/Firestore, cfg.ToStore on IndexedDB, cfg.ToPartition and cfg.MapIndexedProperty on Azure Table/DynamoDB).

See Migrating v12 → v13 for the full old→new table.

BREAKING

A document type carries one spatial / vector / full-text mapping. Declaring a second one used to silently replace the first; it now throws, naming both properties. The old behavior hid real configuration mistakes.

Feature

Raw JSON terminals on the typed query — end a Query<T>() with JSON instead of T, so a document that only has to reach an HTTP response never becomes an object:

// one document, no parse at all
var raw = await store.Query<Order>().Where(o => o.Id == id).FirstOrDefaultRawJson();
return Results.Content(raw, "application/json");
// a whole list, written straight to the response — never buffered, never re-serialized
ctx.Response.ContentType = "application/json";
await store.Query<Order>().Where(o => o.Status == "open")
.OrderByDescending(o => o.CreatedAt)
.WriteJsonArrayTo(ctx.Response.Body, ct);

The full typed builder still applies — Where, OrderBy, Paginate, global query filters, soft delete, tenancy — and the single-row terminals still push their row limit down. Alongside the raw lane there is a node lane returning JsonObject: ToJsonList, ToJsonAsyncEnumerable, FirstJson / FirstOrDefaultJson, SingleJson / SingleOrDefaultJson, ToJsonCursorPage, all over the new RawJsonRows primitive.

Available on every provider. The relational stores and Cosmos DB hand back the persisted body untouched; elsewhere the provider has to materialize T to finish the query, so the body is re-serialized through the type’s JsonTypeInfo — same JSON, same API, but the round trip is real. A type with encrypted properties throws: the stored body is ciphertext and only the typed terminals decrypt. Materialized computed properties and blob payloads live outside the body and so do not appear. See Raw JSON results.

Enhancement

OData and the AI query tool read through the JSON lane. Both built the response by deserializing each document and immediately serializing it back — ODataDocumentQuery.Execute did two passes per document plus a discarded object graph, and the AI query tool did three (deserialize → serialize → parse). Both now read the stored bodies instead, so a whole-entity OData page and an LLM tool result cost one parse. No API change and no change to the response shape; $select was already JsonObject-native and is untouched.

Each picks its lane through a new IDocumentQuery<T>.SupportsRawJsonfalse for a type with encrypted properties and after Select/Project/GroupBy — and falls back to the typed path rather than failing, so an encrypted entity set keeps behaving exactly as it did.

Enhancement

store.Collection<T>() — the generic spelling of store.Collection(typeof(T)) for the JSON collection lane. Identical behavior.

Feature

Validate-on-build — one configuration sweep when the store is constructed, reporting every problem together through a new DocumentConfigurationException instead of one per restart. It catches features the chosen backend does not have (a vector mapping on LiteDB, cfg.Table on RavenDB) and randomized-encrypted properties used where the database has to read through them — a full-text index, a computed expression, a spatial or vector payload, the concurrency version. DocumentConfigurationValidator.Collect(options) returns the same list without throwing.

The relational providers stay permissive: mapping a vector on plain SQLite is still valid and simply skips the ANN index until Shiny.DocumentDb.Sqlite.VectorSupport is added.

Feature

DocumentContext model hook — a source-generated context can declare its whole document model next to its [Document] list instead of inside AddDocumentStore:

[Document(typeof(Patient))]
public partial class AppContext : DocumentContext
{
static partial void OnConfiguring(DocumentModelBuilder model)
=> model.Document<Patient>(cfg => cfg.MapTemporal(o => o.Retention = TimeSpan.FromDays(90)));
}

It runs after the attribute-derived mapping, so what it sets wins. options.ConfigureModel(model => …) exposes the same root outside a context.

Enhancement

indexKind now defaults to the provider’s own — leave it unset on cfg.MapVectorProperty and each backend fills in what it prefers (DiskANN on Cosmos, HNSW elsewhere) when the store is built, rather than each options class baking a different default into its signature.

Enhancement

Shared per-type mapping state, finished. The relational DocumentStoreOptions now delegates to the same DocumentMappingRegistry as every other provider, and spatial/vector mappings live there too — the four duplicated SpatialMapping types (core, Cosmos, MongoDB, Redis) collapse into one, behind shared SpatialMappingFactory / VectorMappingFactory helpers. Version, full-text and computed JSON-path resolution now all go through the JsonTypeInfo-aware resolver on every provider, so source-generated contexts and [JsonPropertyName] are honored where previously only the relational path consulted them.

Feature

Microsoft.Extensions.VectorData connector — new Shiny.DocumentDb.Extensions.VectorData package. Point the .NET AI ecosystem (MEAI, the Microsoft Agent Framework, Semantic Kernel) at a document store through MEVD’s VectorStore / VectorStoreCollection<TKey, TRecord>:

builder.Services.AddDocumentDbVectorStore(o =>
{
o.DatabaseProvider = new SqliteDatabaseProvider("Data Source=app.db") { EnableVectorExtension = true };
o.MapVectorRecord<Note>(); // reads [VectorStoreKey]/[VectorStoreVector] → MapVectorProperty<Note>
});
var notes = sp.GetRequiredService<VectorStore>().GetCollection<string, Note>("Note");
await foreach (var hit in notes.SearchAsync(query, top: 5,
new VectorSearchOptions<Note> { Filter = n => n.Tag == "release" })) { }

Every other MEVD connector is single-store; this one runs the same record model over any 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 Expression<Func<T, bool>>, so it is handed to NearestVectors untouched and still pushed into the ANN search where the provider supports it.

Vector-capable providers only (SQLite+sqlite-vec, PostgreSQL, CockroachDB, SQL Server, Oracle, DuckDB, CosmosDB, MongoDB Atlas, Amazon DocumentDB, Redis); the rest throw NotSupportedException at construction. Dynamic (Dictionary<string, object?>) collections and multi-vector records are not supported. See VectorData Connector.

Enhancement

MapVectorProperty on IDocumentStoreOptions. The provider-agnostic options slice gained a MapVectorProperty<T> overload, so a cross-cutting feature can map an embedding on any backend instead of binding to one provider’s concrete options class — that is how MapVectorRecord<T> works everywhere. Pass null for the index kind to take the provider’s own default (DiskANN on Cosmos, HNSW elsewhere). Providers with no vector engine throw. Purely additive; the strongly-typed per-provider overloads are unchanged.

Feature

Single-row query terminals. First, FirstOrDefault, Single and SingleOrDefault now end a query with one document — with predicate overloads (First(x => x.Age == 40)) and string-grammar overloads (First("status == 'open'")) — on every provider, including the raw JSON collection lane.

They are not sugar over ToList(): the row limit reaches the provider, so a relational store emits LIMIT 1 (or the dialect’s equivalent) and MongoDB/Cosmos page server-side instead of materializing every match to use the first. Single fetches two rows so “more than one matched” costs no extra round trip. Query filters still apply, and the terminal takes the first row of the current Paginate window — see Querying.

Feature

ExecuteUpdate can set several properties in one statement.

await store.Query<Order>()
.Where(o => o.Status == "open" && o.CreatedAt < cutoff)
.ExecuteUpdate(b => b
.Set(o => o.Status, "expired")
.Set(o => o.ClosedAt, DateTimeOffset.UtcNow));

The predicate is evaluated once and the write is atomic on its own, where three separate calls were three statements with a window between them. Relational providers nest the dialect’s JSON-set expression N deep, MongoDB combines the assignments into one $set, and Cosmos applies them in the same read-modify-write pass. The JSON collection lane gets the same thing via ExecuteUpdate(IReadOnlyDictionary<string, object?>).

Feature

Field-level encryption, in the core package. Map a property once and it is AES-256-GCM ciphertext everywhere it is stored, on every provider, with no change to how documents are read or written:

opts.UseEncryptor(new AesGcmDocumentEncryptor("k1", key));
opts.MapEncryptedProperty<Patient>(x => x.Ssn); // opaque
opts.MapEncryptedProperty<Member>(x => x.Email, EncryptionMode.Deterministic); // still queryable by equality

It is installed as a JsonTypeInfo modifier, so every write path — including temporal history and backup export — is covered by construction, and no provider needs to know about it. Deterministic mode keeps equality filters working by rewriting the predicate’s constant into ciphertext (and leaks equality/frequency, which the docs say in bold); anything that cannot be answered against ciphertext throws with an explanation instead of matching nothing. Key rotation is a key ring plus RewrapAsync<T>(), and values written before the property was mapped keep reading, so it can be turned on for a populated store. AOT-clean, and no new dependency — AES-GCM is in the BCL. See Field-level encryption.

Fix

A spatial predicate over an unmapped geometry property answered about the mapped one. On PostgreSQL, SQL Server, MySQL/MariaDB, Oracle and DuckDB, DocumentFunctions spatial predicates are served from the spatial sidecar’s geometry column — which only ever holds the property named by MapSpatialProperty. A Where naming a different geometry property was translated against that column anyway, so it silently returned the answer for the mapped property instead. SQLite, Cosmos and MongoDB address the JSON path directly and answered correctly, but unindexed.

Both are now rejected, on every provider, with a message naming the mapped properties:

// Delivery maps Route as its spatial property
store.Query<Delivery>().Where(d => DocumentFunctions.Intersects(d.Destination!, zone));
// NotSupportedException: 'destination' is not a mapped spatial property … Mapped: 'route'.

This covers the LINQ surface, the string grammar (Where("intersects(destination, …)")), and OrderBy(d => DocumentFunctions.Distance(…)). A document type still carries one spatial mapping; to query a second geometry, map it — or model both shapes as a single GeoGeometryCollection when they are one semantic slot. See Spatial.

BREAKING

DocumentBulkContext.Assignment is now Assignments. A set-based update can carry several property assignments, so the single nullable tuple became an ordered IReadOnlyList<(string Property, object? Value)> — one entry for the single-property overload, empty for Delete/Clear.

var value = ctx.Assignment!.Value.Value;
var value = ctx.Assignments[0].Value;
BREAKING

IDatabaseProvider.BuildJsonSetExpression() now takes the source expression and parameter names (BuildJsonSetExpression(string sourceExpression, string pathParameter, string valueParameter)), so N assignments can nest into one statement. Only affects custom IDatabaseProvider implementations; every in-box provider is updated.

BREAKING

IDocumentStoreOptions gained SerializerOptions and EnsureSerializerOptions() — the seam serialization-level features (field encryption) use to attach a JsonTypeInfo modifier. Only affects custom options classes implementing the interface; every in-box options class implements it explicitly.

Feature

ShinyDocDbMyAdmin now has a terminal front end, installable as a dotnet tool: dotnet tool install -g ShinyDocDbMyAdmin.Tui, then shinydocdb. Everything the web app does — browse and edit documents, both query consoles with their plans, structure and indexes, temporal history with diffs and restore, geometry, full text, vectors, blobs, import/export, the data generator and the read-only AI assistant — in a terminal, with no container and no browser.

It is not a second implementation. The admin layer moved into a shared project both front ends reference, so this reads the same ~/.shinydocdbmyadmin/admin.db, protects secrets with the same key file and writes the same connection bundles. A connection saved in one is a connection the other opens, and a bundle written by either imports into the other — there is a test that does exactly that round trip.

shinydocdb export <file> [--secrets] and shinydocdb import <file> run headless, so a connection list can live in a dotfiles repo. Geometry is drawn offline in braille cells, keeping the SVG map’s rule that no coordinate is ever sent to a tile server. See Terminal UI.

Enhancement

The demo sample now covers full text and vectors. deploy/demo/seed-demo.sql creates the FTS5 index and the per-type triggers exactly as SqliteDatabaseProvider writes them, and every Product carries a 32-float embedding. Both tabs previously had nothing behind them in the public playground, which meant they could not be seen without building a store by hand.

Enhancement

Document ids open the document in ShinyDocDbMyAdmin. In the History, Blobs and Geometry listings the document id is now a link rather than text: click it and the live body opens in a read-only overlay. Those tables are all about a document they never show, and an id on its own rarely tells you which one. A document that no longer exists — history kept past a delete, a payload that outlived its owner — says so in the overlay rather than erroring.

Feature

A data generator in ShinyDocDbMyAdmin. Analyze a type and make more documents that look like the ones already there — for filling a dev database, giving a query plan something to chew on, or reproducing “it gets slow past 100k”.

The analysis learns the range every number occupies, the span every date covers, the set every categorical field draws from, how often each optional field is present, and the shape of nested objects and arrays; it reports all of it per field, with the values it will draw from, so the inference is inspectable. Every generated value comes from the real data — data generated from a schema alone has plausible types and implausible values, which is no use for judging a plan. Ids are the exception: always replaced, and if the existing ones follow a scheme (art-0001 … art-0400) generation continues at art-0401 rather than mixing GUIDs in beside them.

Preview then commit, and the two are the same documents: the preview’s seed is replayed on write.

Two documented limits. Bulk generation goes through the schema-free lane — one transaction for the batch rather than one per document — and that lane maintains no sidecars, so generated rows are absent from a type’s temporal history, vector index and spatial index; the tool reports which of those apply to the type you generated into and links to the vector rebuild. Full-text is unaffected, being engine-maintained. And a free-text field with more distinct values than are worth tracking is sampled rather than invented, so long strings repeat.

Feature

Import and export the connection list in ShinyDocDbMyAdmin. Moves connections — with their saved queries and assistant settings — between instances as plain, readable JSON.

Secrets are left out by default, because an export that carries connection strings is a credential file and that is not what you want to hand around. Opting in encrypts them under a passphrase you type, deliberately not the instance’s own key — that key would make the file useless anywhere but the machine that wrote it, which is the one place an export is not needed. PBKDF2-SHA256 plus AES-GCM, with a fresh salt per export.

Importing shows a review first: every connection in the file, whether one already exists by id or name, and what will happen to it — defaulting to skip for anything already present. A wrong passphrase is caught before the first write rather than halfway through. Host-provided connections are not exported, since they are declared where the app is hosted rather than owned by the instance.

Feature

Demo mode in ShinyDocDbMyAdmin. ShinyDocDbMyAdmin:DemoMode turns the whole app into a public playground with one switch. On first start it builds its own SQLite sample from data embedded in the image and publishes it as a read-only connection — no seeding sidecar, no read-only bind mount to get right, and no ReadOnly/DisableAi pair to remember, because demo mode forces both.

It closes editing, adding/editing/removing connections, importing data, importing or exporting settings, the AI assistant, and any way to rebuild the sample. Exporting data stays open — it is most of what someone is at a playground to try, it only reads, and the data is a published sample.

The sample is written once and never rebuilt, so an image update cannot discard a database someone is looking at and a visitor cannot wipe what everyone else came to see. Every closure is enforced below the UI — ProfileStore refuses the save, the import service refuses the import, the AI services are not registered — so a deep link gets a refusal rather than a half-rendered form.

A bright band across the top of every page marks the instance as a demo and expands to list exactly what is switched off, so missing buttons read as policy rather than breakage.

It ships as a published tag — ghcr.io/shinyorg/shiny-docdb-myadmin:demo, alongside :latest — so a playground is one docker run. Same build, one extra ENV layer, every other layer shared; the two are interchangeable with the flag in either direction — one docker run, no stack file needed.

Fix

The data generator no longer scrambles positional arrays. A fixed-length array was profiled as one merged element shape, so every slot drew from the same pooled range. For a GeoJSON coordinates pair that meant a longitude could land where the latitude belongs — documents describing New York generated points in Antarctica, structurally valid and geographically nonsense. The same applied to any tuple: an RGB triplet, a min/max pair, a bounding box.

Arrays that were a fixed length across every sampled document are now profiled per position, so each slot generates from its own observed range. Variable-length arrays — ordinary collections — are unchanged, and so are fixed arrays longer than eight elements, which are lists rather than tuples.

Fix

Generated GUIDs are drawn from the run’s seed. The Generate tab promises the commit writes the documents the preview showed — “the preview’s seed is replayed on write” — but GUID ids and GUID-valued fields came from Guid.NewGuid(), outside the seeded RNG. Any type whose ids aren’t a numeric scheme therefore previewed one set of documents and wrote another. They now come from the same seeded generator, so a replay reproduces the rows exactly.

Feature

An AI assistant in ShinyDocDbMyAdmin. A chat that answers questions about your data by composing the same reads the rest of the tool performs. Configured per connection — point development at OpenAI and leave production on a local model, or off — and available as an Assistant tab beside Browse plus a connection-level page for cross-table questions. Providers: OpenAI, Azure OpenAI, Anthropic (their official .NET library), and any OpenAI-compatible endpoint, which covers OpenRouter, Groq, LM Studio, vLLM and Ollama.

It cannot write. The assistant gets nine tools and every one is a read — no insert, update or delete, and no raw SQL. Read-only is a property of the tool surface rather than an instruction in a prompt, and a test pins the exact list so a write tool cannot be added by accident. Results are capped per call, with truncation reported to the model so it does not present a partial set as a total.

What it can see is stated plainly, up front. It reads every table in every connection configured in the tool, and what it reads goes to your chosen provider — but nothing is sent until you send a message: no background indexing, no schema pre-fetch, no telemetry. Connection strings and passwords are never included, each reply lists the tools that ran, and the transcript stays in memory rather than being written to disk.

For a public demo instance, ShinyDocDbMyAdmin:DisableAi removes the feature outright — no tab, no settings link, services unregistered, and the routes refuse to render rather than merely being unlinked. From an Aspire AppHost, the new .WithoutAi() on AddDocumentDbAdmin does the same.

Enhancement

Per-row links into the sidecars in ShinyDocDbMyAdmin. The Browse grid already linked each row to its History; it now links to that row’s Blobs and Geometry as well. Every sidecar is keyed by document id, so these open the same tab the header would, already narrowed to the one document — and each tab keeps a control to widen back out to the whole type.

A link only appears when the thing behind it exists ({table}_history, {table}_blobs, or GeoJSON in the type), so a plain type still gets a plain row. The Geometry scope reaches the query rather than filtering after the fact, so drawing one document’s shape out of a large type does not read the type end to end.

Enhancement

The document-type workspace holds still while you scroll. The type name and its tabs stay put, the panel between them scrolls, and pagers and footer summaries ride the bottom of the window — so switching tabs never means scrolling back up to find them, and the row count and next-page button are where you left them. In the Browse grid the column headers stay too, which is what makes a long page of rows readable.

Narrow screens keep one ordinary page scroll: the shell stops being a fixed-height frame below 860px, and vertical space there is too scarce to spend on pinned chrome.

Feature

Query plans and one-click indexing in the ShinyDocDbMyAdmin filter console. Explain runs the provider’s own plan over the SQL a filter compiled to — EXPLAIN QUERY PLAN (SQLite), EXPLAIN (PostgreSQL, CockroachDB, MySQL, MariaDB, DuckDB), SHOWPLAN_TEXT (SQL Server), DBMS_XPLAN (Oracle). The statement is planned, not executed, so it works on a read-only connection, which is where “why is this slow” is usually asked.

Underneath the plan, any field the query filters or sorts on that has no index is offered as a one-click create — individually or as a composite over all of them — and the query then re-runs and re-explains, so the plan on screen is the one after the change rather than a claim that it helped.

Two limits stated in the UI: the unindexed-field list comes from the filter text rather than the plan (engines name the object scanned, not the JSON path inside it), and the “looks like a scan” note is a text match on the plan — on a small table a scan is often correct, so the plan itself is always shown.

Feature

A Full text tab in ShinyDocDbMyAdmin. Ranked search through the provider’s own engine — FTS5 BM25, PostgreSQL ts_rank, SQL Server FREETEXTTABLE, Oracle CONTAINS, DuckDB match_bm25 — with the SQL it ran shown underneath. It needs no registered mapping: every provider derives the index table or column from the table and type name alone. PostgreSQL is the one exception, needing the to_tsquery language, hence a language selector that must match the language the index was built with.

Worth stating explicitly, because it differs from the vector sidecar: there is nothing here to keep in sync. Full-text indexes are maintained by the database itself, so a document written or deleted through the admin tool is immediately searchable, or immediately gone. The exception is DuckDB, whose index is a snapshot the library rebuilds before each query; the tab reports that writes leave it stale and that the tool cannot rebuild it, since that needs the field list only MapFullTextProperty has.

Enhancement

ShinyDocDbMyAdmin lists every index, and can build composite ones. The Structure tab showed only indexes named idx_json_*, so an index added by hand was invisible — and invisible is exactly the wrong thing for the index that explains why a query is fast. It now lists all of them, labelling the ones DocumentDb did not create and refusing to drop those. Where the engine tracks it, each index shows its size and how often the planner has used it (PostgreSQL and SQL Server report both, Oracle size only; SQLite, MySQL and DuckDB report neither, shown as rather than a fabricated zero), and a never-used index is flagged.

Index creation now accepts several comma-separated paths and builds a composite index through the provider’s multi-path builder — the capability existed on IDatabaseProvider but the tool only ever called the single-path form. Composite index names are also split back into their paths for display instead of being shown as the raw a__b.

Enhancement

IDatabaseProvider gained BuildExplainSql, BuildListAllIndexesSql and BuildFullTextProbeSql. BuildExplainSql returns a list of statements because two providers need more than one to produce a plan (Oracle populates a plan table then formats it; SQL Server toggles a session setting around the statement) — the plan is the last result set that returns rows. BuildListAllIndexesSql selects name, definition, size and scan count, with nulls where an engine tracks neither. BuildFullTextProbeSql is a catalog query returning rows when a type is indexed — deliberately a row count rather than a statement that throws, because a failed statement poisons an open transaction on PostgreSQL. All three are default interface members returning null/empty, so no provider outside this repo needs a change.

FullTextMappingFactory.SanitizeSuffix now forwards to IDatabaseProvider.SanitizeTypeSuffix rather than being a second character-identical copy of it.

Feature

A filter-grammar mode in the ShinyDocDbMyAdmin query console. Alongside the raw SQL tab, the console now takes DocumentDb’s own string query syntaxWhere, OrderBy and an optional Project, against a table and type you pick:

status == 'Shipped' and total:number > 100

It is not a second implementation of the grammar. The console opens store.Collection(typeName) — the schema-free JSON collection lane, which needs no CLR type, exactly like the tool — so your text goes through the same parser, translator and SQL builder your application uses, and the answer is the answer your code would get. The compiled SQL is shown next to the results with its bound parameters, and one button drops it into the raw SQL tab, which makes the grammar learnable by using it.

Two documented limits: a store configured with TypeNameResolution.FullName writes dotted type names, which the collection lane’s name validation will not address (the console says so and points at the SQL tab), and the grammar addresses fields inside Data rather than the envelope columns.

Feature

DocumentStoreOptions.SkipTableInitialization. Suppresses the lazy CREATE TABLE IF NOT EXISTS / index DDL that otherwise runs once per table on the first operation against it — including on reads. For pointing a store at a database this process does not own: a read replica, an account with no DDL rights, or a tool that has told the user it will not change anything. The table must already exist; if it does not, the first query fails with the provider’s own “no such table” error rather than creating one. Defaults to false, so nothing changes unless you ask for it.

var store = new DocumentStore(new DocumentStoreOptions
{
DatabaseProvider = provider,
SkipTableInitialization = true
});
Fix

Re-embedding a document on SQLite no longer throws. Update/Upsert of a vector-mapped document that already had an embedding failed with SQLite Error 1: 'UNIQUE constraint failed on {table}_vec_{type} primary key'. The sidecar upsert used INSERT OR REPLACE, and sqlite-vec’s vec0 virtual table does not implement SQLite’s conflict-resolution clauses — so replacing an existing rowid was a hard constraint failure rather than a replace, and only the first write to a given document ever succeeded. The upsert now deletes the row before re-inserting it. SQLite only; every other vector-capable provider used a real upsert already.

Fix

ShinyDocDbMyAdmin no longer desynchronises the vector sidecar. Document writes in the admin tool go through raw SQL, and while they already maintained the blob and temporal sidecars, they ignored {table}_vec_{type} entirely — so editing a document left a stale embedding in the ANN index, deleting one left an orphan row, and clearing a type left the whole sidecar populated. None of it errored; a nearest-neighbour query simply started returning wrong answers. Saves now re-index the embedding, deletes remove its row, and Clear empties the sidecar, all inside the same transaction as the document write.

Where the sidecar cannot be written, the tool says so rather than silently proceeding: SQLite’s sidecar is a vec0 virtual table that needs the sqlite-vec extension, which the library loads and the admin container does not ship. That case now reports “the document was saved, but the vector sidecar now holds the previous embedding” and links to the repair.

Feature

A Vectors tab in ShinyDocDbMyAdmin. Embeddings are discovered by sampling document bodies — no CLR type or registered mapping needed — and the tab reports dimensions, L2 norms and how many vectors are unit length, plus the failure modes that never raise an error: all-zero vectors (an embedding that was never generated), NaN/infinity components, and mixed dimensions at one path.

It also reconciles the {table}_vec_{type} sidecar against the documents, listing embeddings that never reached the index and rows pointing at deleted documents, with a one-click rebuild from the document bodies. Nearest-neighbour search runs from an existing document’s own embedding or a pasted vector under cosine, Euclidean or dot-product distance — computed in the tool over a bounded scan rather than pushed down, which makes it exact rather than approximate, identical on every backend, and usable precisely when the sidecar is the thing you suspect.

See Admin UI.

Enhancement

Embeddings no longer flood the admin’s read-only views. A mapped 1536-dimension vector was rendered element by element in the JSON tree and, in the browse grid, written out in full as both the cell text and its title attribute — tens of kilobytes per row over the Blazor circuit to display numbers nobody reads. Long numeric arrays now render as float[1536] [0.021, -0.114, …]; the raw toggle still shows every component.

Enhancement

IDatabaseProvider gained VectorTableName(table, type) and BuildVectorDocIdsSql(table, type). The first defines the {table}_vec_{type} sidecar naming convention in one place — the counterpart to the existing HistoryTableName and BlobTableName — replacing six identical private copies across the vector-capable providers, and letting a tool locate the sidecar without a registered VectorMapping. The second selects the document ids the sidecar currently holds so it can be reconciled against the documents table; SQLite overrides it to read the companion map table, which is both where the ids actually live and the only half readable without sqlite-vec loaded. Both are default interface members, so no provider outside this repo needs a change.

Fix

Shiny.DocumentDb.Sqlite.VectorSupport Android binaries are now 16 KB page aligned. The sqlite-vec .so shipped for arm64-v8a and x86_64 came from the upstream release artifacts, which are linked with 4 KB page alignment. On an Android 15+ device running 16 KB memory pages the load failed with dlopen failed: ... program alignment (4096) cannot be smaller than system page size (16384), taking SqliteVec.RegisterAutoExtension() down with it — and Play Store has required 16 KB support for apps targeting Android 15+ since November 2025. The Android ABIs are now compiled from the sqlite-vec amalgamation with -Wl,-z,max-page-size=16384 (same flags and vendored SQLite headers upstream uses, so the exported surface is unchanged), and the build fails if any 64-bit segment regresses. Verified on a 16 KB-page Android emulator. No API change — update the package.

BREAKING

The late-bound JSON lane is now store.Collection(type). The eight Type + JsonNode members on IDocumentStore are removed in favour of a single JSON collection surface that serves both keyings. No [Obsolete] shims — update the call sites.

Removed Replacement
store.Insert(type, node) store.Collection(type).Insert(obj) / .Insert(array)
store.Update(type, node) store.Collection(type).Update(node)
store.Update(type, node, patch) store.Collection(type).Update(node, patch)
store.Upsert(type, node) store.Collection(type).Upsert(node)
store.Upsert(type, node, patchIfUpdate) store.Collection(type).Upsert(node, patchIfUpdate)
store.Get(type, id) store.Collection(type).Get(id)
store.Query(type, where, parameters) store.Collection(type).Query(where, parameters)
store.QueryStream(type, where, parameters) store.Collection(type).QueryStream(where, parameters)

Three signature changes come with it: Insert of a single object returns the stored id rather than a count of 1; Get returns JsonObject? rather than JsonNode? (stored bodies are always objects); and Insert no longer accepts a bare JsonNode — call .AsObject() or .AsArray(), because the return type differs between the two shapes and overload resolution would otherwise depend on how a variable happens to be declared. Update and Upsert still take JsonNode and accept either shape.

Feature

Schema-free JSON collections — store.Collection("orders"). Store and query documents whose shape is unknown at compile time: no CLR type, no registered mappings, no migrations. The same IJsonDocumentCollection the type-keyed form returns, so there is one API rather than two.

var orders = store.Collection("orders");
var id = await orders.Insert(jsonObject); // returns the stored id
var doc = await orders.Get(id);
var rows = await orders.Query()
.Where("customer.name == 'bob' and total:number > 100")
.OrderBy("total:number desc")
.Paginate(0, 50)
.ToList(); // IReadOnlyList<JsonObject>

The collection name becomes the row’s TypeName, so a schema-free collection shares a table with your typed documents without either seeing the other — and needs zero schema change.

Querying is the string grammar only (no typed LINQ — there is no T to write a lambda against), lowered to the same IR and the same SQL as Query<T>().Where("…"). Because there is no metadata saying what a field is, its type is inferred as the expression is built: from an explicit path:type hint, else from the other operand (total > 100 is numeric), else from the function (lower(name) is a string, year(created) a date), else string — which every dialect emits as the plain untyped extract, so the SQL is byte-identical to an unhinted one. You need a hint wherever nothing else pins the type — an OrderBy, a min/max, or a Project over a numeric field — because on every provider whose plain JSON extract returns text, OrderBy("total") sorts lexicographically ("100" before "9"). SQLite is numerically correct either way. Hint vocabulary: string, number, int, long, double, decimal, bool, date, guid.

Ids: the id property is per-collection (default "id"), read case-insensitively and written verbatim. An absent id generates a sortable UUIDv7 string and stamps it into your object — deliberately different from the type-keyed lane, where a declared Guid generates a v4 and a declared string id refuses to auto-generate. Schema-free ids compare as literal strings: pass back exactly what you stored.

Names and paths are validated against ^[A-Za-z_][A-Za-z0-9_]{0,127}$ before they reach the DDL, because they are interpolated into SQL as literals rather than bound as parameters. The documented consequence is that JSON keys containing dashes, spaces or dots are storable but not addressable.

Provider tier: the relational providers (SQLite, SQLCipher, DuckDB, PostgreSQL, CockroachDB, SQL Server, MySQL, MariaDB, Oracle). Everything else throws NotSupportedException, and the tier is pinned by a test per provider. Not available on a schema-free collection: interceptors, temporal history, change notifications, versioning/CAS, spatial/vector/full-text/computed/soft-delete mappings, and the hasflag/geo/Lucene grammar functions — every one of those is registered against a CLR type, so those functions throw with a pointer to Query<T>(). Global query filters are Expression<Func<T,bool>> and so do not apply. Like the lane it replaces, this is a store-level feature: reach it from a session via session.Store.Collection(...).

See JSON Collections.

Enhancement

Type-keyed JSON collections gained a query builder and deletes. store.Collection(type) is the same object as the schema-free form, so it picks up two things the old lane never had: a fluent string-grammar Query() (previously the only option was the raw-SQL Query(type, whereClause), which is still there as Collection(type).Query(where, parameters)), and Remove / BatchRemove / Clear, which the lane did not offer at all. Field paths resolve through the document type’s metadata, so naming policies, [JsonPropertyName] and leaf types are all honoured — and a type-keyed collection therefore reaches the full function set, including hasflag, the geo predicates and the Lucene functions. A conformance suite runs every applicable case against both keyings, and a parity suite asserts that the same filter string selects the same documents on Query<T>(), Collection(type).Query() and Collection(name).Query().

Fix

Batched merge writes no longer fail on PostgreSQL, SQL Server and CockroachDB. The RFC 7396 fallback used by providers without a native JSON merge-patch function opened its own transaction for the row-locking read-modify-write. Inside a batch — which the caller already wraps so the whole batch is atomic — that was a nested transaction, which those drivers reject outright (a transaction is already in progress). It now joins the ambient transaction when there is one. This affected any array/batch upsert or patch: true update on those providers.

Feature

ShinyDocDbMyAdmin — a phpMyAdmin-style web UI for DocumentDb stores. Browse, edit and query the documents in any relational store from a browser: a sampled-column grid with JSON-path filters, a formatted/collapsible JSON view, a JSON editor, inferred structure with one-click index create/drop, temporal history with version diffing and restore, a GeoJSON map, blob listing with inline preview, streaming import/export (JSON, NDJSON, CSV, envelope), and a parameterised SQL console.

Shipped as a container image only — docker run -p 8085:8080 -v shiny-docdb-myadmin:/data ghcr.io/shinyorg/shiny-docdb-myadmin. Covers the relational providers (SQLite, SQLCipher, DuckDB, PostgreSQL, SQL Server, MySQL, MariaDB, Oracle 23ai+, CockroachDB); the document stores are out of scope because the tool works against the shared Id / TypeName / Data / CreatedAt / UpdatedAt envelope over ADO.NET.

See Admin UI.

Feature

AddDocumentDbAdmin models the admin UI as an Aspire resource. It comes up with the rest of your app and every store you WithReference is already connected — it reads the same ConnectionStrings:{name} + Shiny:DocumentDb:{name}:Provider pair the client integration does, so a reference with no provider key is ignored rather than becoming a junk connection.

var store = builder.AddPostgresDocumentStore("orders");
builder.AddDocumentDbAdmin(port: 8085)
.WithReference(store)
.WithDataVolume()
.WithReadOnly()
.WaitFor(store);

Chain WithHostPath to reach a file-backed store from inside the container, and WithSecretKey to pin the key that encrypts saved connection profiles. The image tag defaults to the hosting package’s own version, so an integration upgrade brings the matching UI with it. See Aspire.

Enhancement

Every shipping package is now trim/AOT warning-free, and declares it. The whole src/ tree builds with zero IL2026/IL2075/IL2090/IL3050 warnings, and packages carry IsAotCompatible — which stamps [AssemblyMetadata("IsTrimmable","True")]. Previously no package declared it, so a consumer’s trimmer kept these assemblies whole instead of trimming into them. The four packages whose dependencies rule AOT out (AspNetCore.OData, Aspire.Client, Aspire.Hosting, Aspire.Orleans, RavenDb) declare IsAotCompatible=false honestly rather than silently.

Enhancement

Mapping APIs annotate their document type for the trimmer. MapVersionProperty, MapSpatialProperty, MapVectorProperty, MapFullTextProperty, MapComputedProperty, MapBlob, and MapBlobCollection — on the core options, on every provider’s options, and on the shared DocumentMappingRegistry — now declare [DynamicallyAccessedMembers(PublicProperties)] on T, so the properties they resolve by name are actually preserved under trimming. Previously these carried IL2075 suppressions that did not match the code the analyzer emits (IL2090), so the warnings were live and nothing was preserved.

Passing a concrete type (MapSpatialProperty<Place>(…)) needs no change. Code that forwards its own unannotated generic parameter into these methods will now get IL2091 and should propagate the same annotation:

// before: silently unannotated
static void Configure<T>(DocumentStoreOptions o, Expression<Func<T, int>> version) where T : class
=> o.MapVersionProperty(version);
// after
static void Configure<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(
DocumentStoreOptions o, Expression<Func<T, int>> version) where T : class
=> o.MapVersionProperty(version);
Fix

Spatial and blob serialization no longer route through the caller’s reflection resolver. Geometry and DocumentBlob are (de)serialized through an internal source-generated context — both carry a type-level [JsonConverter], so the contract is a converter lookup and nothing reflects over their members. SpatialJson gains FromGeoJson and FromNode and is now the single GeoJSON entry point for the core, SQLite, Cosmos, and MongoDB providers, which had each rolled their own.

Fix

EnumJsonStorage used Enum.GetValues(Type) (RequiresDynamicCode) to probe string-stored enums; it now uses Enum.GetValuesAsUnderlyingType, which never has to construct an array of the enum type at runtime.

Fix

The AI tool schema builders and the Firestore cursor encoder bound JsonArray.Add<T> — the reflective generic overload — where the non-generic Add(JsonNode?) was meant.

Enhancement

AOT is verified on every build, not just analyzed. samples/Sample.Aot publishes with PublishAot=true, exercises each mapping kind and every query surface once, and treats any trim/AOT warning as a build error. CI publishes and runs it. The Roslyn analyzers cannot see warnings coming out of dependencies or code reachable only through a call graph — a real ILC run can. The analyzers were also re-enabled on six packages that had them switched off (Orleans.CosmosDb, Orleans.MongoDb, RavenDb, Aspire.Client, Aspire.Hosting, Aspire.Orleans); all six were already clean, so the opt-outs were only hiding future regressions.

Enhancement

Two permanent AOT limitations are now documented rather than discovered at runtime — grouped projections must target a named type with a JsonTypeInfo (an anonymous type cannot have one), and the anonymous-type parameter bag on the string-query overloads is not trim-safe, so pass IDictionary<string, object?> instead. See AOT Setup.

BREAKING

Query builders are immutable everywhere — the relational query no longer mutates in place. Where, OrderBy, OrderByDescending, Paginate, and IgnoreQueryFilters on the relational DocumentStore used to add to the query object and return the same instance; every document provider (MongoDB, Cosmos, LiteDB, Redis, RavenDB, Firestore, Azure Table, DynamoDB, IndexedDB) has always returned a copy. Both now return a copy, which is also what IQueryable/EF Core callers expect. Code that relied on the relational behavior silently loses its clause:

var q = store.Query<User>();
if (activeOnly)
q.Where(x => !x.IsDeleted); // ← was applied on SQL, discarded everywhere else; now discarded everywhere
await q.ToList();
q = q.Where(x => !x.IsDeleted); // ← assign the result (correct on every provider)

Sweep for builder calls used as statements — the compiler cannot flag them.

BREAKING

Composing a query after Select(...)/Project(...) throws NotSupportedException everywhere. The relational providers threw InvalidOperationException for these ~20 guards while the nine document providers threw NotSupportedException. They are all NotSupportedException now. Only code that catches the specific type is affected; the messages are unchanged. (The unrelated “this operation requires a JsonTypeInfo<T>” errors stay InvalidOperationException.)

Feature

IDocumentStoreOptions — one extension point for cross-cutting features on every provider. A small interface (AddInterceptor / AddBulkInterceptor / AddQueryFilter), implemented explicitly by DocumentStoreOptions and every provider options class, so a feature that only needs those can be written once instead of once per provider. AddSoftDelete<T> collapsed from ten files to one, and MapJsonSchema (previously relational-only) now works on every provider. Each options class keeps its own strongly-typed fluent methods, so existing configuration code is unchanged.

Feature

DocumentQueryBase<T> — the shared query surface the document providers now build on. The nine non-relational IDocumentQuery<T> implementations were 70–88% identical: the same builder state, the same client-side terminals, the same interceptor plumbing, re-typed per provider. They now derive from a public DocumentQueryBase<T> and supply only what is genuinely theirs — a Clone, an ExecuteAsync(QueryPlan<T>), and the two set-based write primitives. ~3,700 lines of duplicated provider code deleted. Push-down is preserved and explicit: ExecuteAsync returns a QueryExecution<T> saying how much of the plan the engine satisfied (Complete for MongoDB/Cosmos, Candidates for the key-value stores, Partial in between), and the base applies only the remainder client-side. Cosmos and MongoDB keep their server-side COUNT/aggregates by overriding the aggregate hooks; Firestore keeps its native keyset cursor. The base is public, so an out-of-repo provider gets the same deal — implement four members instead of ~450 lines. Behavior is covered by a new cross-provider conformance suite (below) that runs on all 17 providers.

Feature

The single-document write pipeline moved to DocumentProviderBase. Every document provider’s Insert/Update/Upsert/Remove opened with the same preamble (resolve type info, id accessor, type name and version mapping; build the write context; run BeforeWrite; honor a cancel; take a replacement document) and closed with the same tail (AfterWrite, then publish the change) — nine copies of it, which is why v11.4’s ctx.Cancel() needed 45 hand-edited guards. That flow is now BeginWriteAsync / ResolveInsertId / RequireDocumentId / CompleteWriteAsync on the base, along with one ChangeBroadcaster and the unit-of-work-buffered PublishChange that five stores each re-implemented. Persistence, conflict handling, and id generation stay per provider — they genuinely differ (Redis SET NX, Cosmos ETag, DynamoDB conditional writes, Azure Table 409). Providers implement four new hooks: Mappings, IdCache, ResolveTypeInfo, ResolveDocumentTypeName.

Feature

Cross-provider conformance suites. DocumentQueryConformanceTestsBase and SoftDeleteConformanceTestsBase assert the IDocumentQuery<T> contract, soft delete, and interceptor cancellation identically on every provider (relational + document), wired through a new IDocumentStoreFixture.CreateStore(table, Action<IDocumentStoreOptions>) hook. They found the four fixes below on their first run, and they are what makes provider-level refactoring safe.

Fix

Boolean properties in a predicate were broken on MySQL, MariaDB, and SQL Server. A bool JSON value was extracted as the text 'true'/'false' and compared against 1/0, so Where(x => x.IsActive) (or any bool query filter, including soft delete’s) failed with Truncated incorrect DOUBLE value: 'false' on MySQL/MariaDB. On SQL Server a bare bool predicate produced An expression of non-boolean type specified in a context where a condition is expectedBIT is a value, not a condition. Both providers now normalize a bool extract to 1/0, and a new IDatabaseProvider.BoolCondition hook wraps it as a condition where the dialect requires it (SQL Server). PostgreSQL, SQLite, DuckDB, Oracle, and CockroachDB were unaffected.

Fix

Cosmos DB: query.ExecuteUpdate(...) failed with a 404. The update read matching items with a narrowed SELECT c.id, c.data projection and wrote the result back with ReplaceItemAsync — so the round-tripped body had no typeName, which is the partition key, and Cosmos rejected the replace as Resource Not Found. The whole item is now selected before the write-back.

Fix

Cosmos DB: SetProperty/RemoveProperty ignored global query filters. Every other provider returns false when the stored document fails a filter (it is invisible), but Cosmos wrote through regardless — so a soft-deleted document could still be updated by id. Both now check the filters before replacing.

Feature

Interceptors can now replace a write, not just observe it — ctx.Cancel(). BeforeWrite gained DocumentWriteContext.Cancel(bool succeeded = true) and BeforeBulkWrite gained DocumentBulkContext.Cancel(int affected = 0). Cancelling means “I performed this write myself”: the store issues no write for the operation, no AfterWrite/AfterBulkWrite fires, no change notification or temporal history entry is written, and later interceptors in the chain are skipped. The caller still gets a normal result — Cancel(false) makes Remove return false, Cancel(n) makes ExecuteDelete/ExecuteUpdate/Clear return n. Previously the only lever was throwing, which aborts the write and the surrounding unit, so an interceptor could never change the shape of an operation (delete → update). Write your replacement through ctx.Store/ctx.Session, which are transaction-bound, so it commits with whatever unit the original write belonged to. To support re-issuing a set-based write differently, DocumentBulkContext also gained QueryAs<T>() (the originating query — same predicate, same filters; null for Clear) and now carries Store. Cancel() is valid only inside the before-hook and throws elsewhere; on providers that write a BatchInsert as one set, cancelling throws NotSupportedException rather than silently writing the row. Supported on every provider. See Replacing a write.

Feature

Soft delete in the box — AddSoftDelete<T>(x => x.IsDeleted). Map the flag once and Remove, query.ExecuteDelete(), and Clear<T>() set it instead of deleting, while every read hides the flagged documents; Query<T>().IncludeDeleted() reads past it and OnlyDeleted() returns just the flagged ones. The flag is a bool (set to true) or a nullable DateTime/DateTimeOffset (stamped from a DI-registered TimeProvider when present). Comes with store.SoftDelete<T>(id), store.Restore<T>(predicate), store.PurgeDeleted<T>(predicate?) (permanently delete flagged documents), store.HardDelete<T>(id), and store.SuppressInterceptors() for a raw write. Because a set-based delete is re-issued as an update over the same query, the spatial/vector/blob sidecar rows simply stay with the document instead of being orphaned. It is not built into the stores: it is a named query filter (soft-delete) plus a cancelling interceptor, composed from the two public building blocks above and shipped as an extension method — AddSoftDelete sits on DocumentStoreOptions and on each provider’s options class (in that provider’s namespace). One flag per document type; a second mapping on a different property throws. See Soft Delete.

Fix

SetProperty / ExecuteUpdate with a date, time, or Guid value no longer writes malformed JSON. On the providers that build the value as a JSON literal (SQLite, PostgreSQL/CockroachDB, MySQL/MariaDB, Oracle, DuckDB), a DateTime, DateTimeOffset, DateOnly, TimeOnly, TimeSpan, or Guid was formatted with an unquoted invariant ToString()json_set then rejected the whole statement (malformed JSON on SQLite). These now serialize as quoted JSON strings in exactly the format a normal document write produces, so the values also compare correctly against date predicates. SQL Server was unaffected (it binds the value natively).

Feature

AI tools — non-removable per-type access filters (Where). IDocumentAITypeBuilder<T> gains Where(Expression<Func<T, bool>>), a fixed server-side predicate that scopes every generated tool for a type and cannot be seen, disabled, or widened by the LLM — it is AND-combined with whatever filter the model supplies (call it more than once to require several conditions). Where AllowProperties/IgnoreProperties gate which fields are visible, Where gates which rows are reachable: query/count/aggregate push it into the store query, get_by_id/delete treat an out-of-scope id as “not found”, insert rejects a document that would fall outside it, and update requires both the incoming document and the stored record it replaces to be in scope (so the model can neither move a record out of scope nor overwrite one it can’t see). Evaluated with the same compile-free, AOT-safe machinery the store uses. Intended for stable scopes (a constant tenant/filter fixed for the singleton registration); for per-request isolation use store-level multi-tenancy or global query filters. See Non-removable access filters.

Feature

Blobs — binary payloads attached to documents, stored in a sidecar and loaded on demand. Map a DocumentBlob (single) or DocumentBlobCollection (many) with MapBlob<T> / MapBlobCollection<T>, and the payload goes to a {table}_blobs sidecar table instead of the document JSON. The document body keeps only metadata — size, content type, file name — so ordinary queries never drag the bytes along, and you can Where/OrderBy on that metadata like any other property. Payloads self-load: await doc.Pdf.LoadAsync() for one blob, await doc.Attachments.LoadAllAsync() for a whole collection in one round trip (or store.BatchLoadBlobs(page) across a page); Bytes throws until loaded, so there is no hidden I/O behind a property getter. Writes go through the document (assign the member and save), so the inline metadata can never disagree with the stored bytes; deletes cascade to the sidecar. Opt-in SHA-256 hashing and a per-mapping size ceiling are available; store.MaxBlobSize reports the provider’s limit. Supported on the relational providers (SQLite, PostgreSQL/CockroachDB, SQL Server, MySQL/MariaDB, Oracle, DuckDB); the NoSQL providers report MaxBlobSize == 0 and throw for now. Use it instead of a raw byte[] property, which is still base64-encoded into the document body. See Blobs.

Fix

DocumentSerialization.Generated now honors type-level [JsonConverter] — spatial types work under Generated. The generated metadata resolver only ever recognized [JsonPropertyName] and [JsonIgnore], so a type carrying its own [JsonConverter] was walked as a plain object and its converter silently bypassed. In practice that made every spatial member unusable under Generated: GeoPoint (an immutable struct) and Geometry (an abstract base) both raised DDB005 at build time, and a mutable converter-backed type was worse — it compiled and persisted the wrong JSON shape with no error. Converter-backed types are now emitted as values constructed from their converter, so GeoPoint, GeoPoint?, and Geometry round-trip as GeoJSON. GeoPointJsonConverter and GeometryJsonConverter became public to make this possible (the resolver is emitted into your assembly and constructs the converter directly — additive, nothing renamed or removed).

The converter must be public, non-abstract, have a public parameterless constructor, and derive from JsonConverter<T> for exactly the declared member type; member-level [JsonConverter], converter factories, and a document type carrying its own converter now raise DDB005 with a specific message instead of being silently ignored. See Generated mode.

Feature

New Shiny.DocumentDb.Firestore.Mobile package — an on-device Firestore store for iOS and Android. Unlike Shiny.DocumentDb.Firestore, which drives Firestore from a server with a service account (bypassing security rules), this provider binds the native Firebase SDK and runs on the device under the end user’s Firebase identity — so the native offline cache, the offline write queue, and snapshot listeners are all the real thing rather than a managed reimplementation. Reads are cache-first and writes queue and drain on reconnect; NotifyOnChange/SubscribeChanges deliver live changes from any writer. LINQ Where/OrderBy/Paginate push down to the native query. Ships with a managed IFirebaseIdentity (anonymous + email/password over the Firebase Auth REST API, with token refresh). Interop is via first-party Shiny bindings to the official Firebase SDKs — not Plugin.Firebase. Register with AddMobileFirestoreDocumentStore(…); other platforms throw PlatformNotSupportedException. It is the first provider built entirely outside this repo, on the extension points opened in 11.1.1, and versions independently (shipping as 1.0.0). See Firestore Mobile — including the current limitations: write interceptors and MapVersionProperty are accepted but inert, and security rules do not yet see request.auth.uid. This is part of a different repo and versioning structure - so make sure to check the Firestore Mobile page for the latest release notes.

Enhancement

Provider extension points are public — you can now build a IDocumentStore provider outside this repo. DocumentProviderBase was public but not implementable: its Interceptors member was internal abstract, so only assemblies holding an InternalsVisibleTo grant could derive from it. That member is now protected abstract (plain protected, not protected internal — under an IVT grant the compiler would demand protected internal override, making the modifier a provider writes depend on whether it has a grant). The supporting types a provider must touch went public alongside it: InterceptorPipeline, and, in Shiny.DocumentDb.Internal, IIdConverter, DelegateIdConverter<TId>, IdConverterRegistry, and VersionMapping. Widening only — nothing was removed or renamed, so no existing code changes.

Feature

Aspire-backed typed DocumentContext. The Aspire client gains builder.AddDocumentContextProvider("name"), which resolves the AppHost-injected connection string + provider discriminator, wires the store health check + OpenTelemetry (honoring DocumentStoreSettings), and returns the Action<DocumentStoreOptions> you hand to a source-generated Add{Context} / Add{Context}Factory method — so a typed DocumentContext can be backed by an Aspire-provisioned database with no hand-wiring: builder.Services.AddOrdersContext(builder.AddDocumentContextProvider("orders")). Because each context registers its store keyed by the context type, calling it once per context (each with its own Aspire name) backs multiple contexts from multiple resources without shadowing. Relational + SQLite providers (the same family as AddDocumentStore). See Typed DocumentContext on an Aspire resource.

Feature

New Shiny.DocumentDb.Geo package — embedded reference geography. A small, provider-agnostic dataset of US states, Canadian provinces, and US & Canadian cities you can seed straight into any store. Register AddGeoReferenceSeeder() (idempotent, runs once) and it writes plain GeoRegion (state/province with a simplified GeoPolygon boundary) and GeoCity (point) documents; call opts.MapGeoReferenceData() to enable spatial queries against them (point-in-region containment, nearest-city, population lookups) on every provider that supports MapSpatialProperty. Prefer working in memory? GeoDataSets.Regions / GeoDataSets.Cities expose the materialized lists for plain LINQ. The embedded city lists are regenerated from US Census TIGERweb and Statistics Canada by a dev-only tool (tools/Shiny.DocumentDb.Geo.DataSeeder). See Reference Geo Data.

BREAKING

The document store is now a connection: IDocumentSession is the unit of work. The single biggest change — it removes the ambient thread-safety/scoping traps that a singleton-store-as-the-operation-API forced. There are now two levels: the long-lived IDocumentStore (singleton — immediate CRUD, queries, change feed, maintenance, backup) and a short-lived IDocumentSession (the EF-DbContext analogue — a unit of work you open, buffer writes on, and commit). Get one with store.OpenSession(), inject IDocumentSession (scoped) in ASP.NET via the new AddScopedDocumentSession(), or open one from the singleton IDocumentSessionFactory (MAUI/desktop/background — mirrors EF’s AddDbContextFactory). The session carries its own DI scope, so scoped interceptors resolve the caller’s services with no AsyncLocal plumbing.

New: explicit transactions on a session — await using var tx = await session.BeginTransaction(); (one active at a time) for locking reads (session.Get(id, LockMode.Update)) and grouping multiple ExecuteUpdate/ExecuteDelete set-based writes; SaveChanges joins the active transaction or opens its own. Explicit transactions are relational-provider only.

Removed (breaking — no shims):

  • IDocumentStore.CreateUnitOfWork() and the public UnitOfWork type → use store.OpenSession() (returns IDocumentSession). The buffered verbs are identical: Add/AddRange/Update/Upsert/Remove + SaveChanges. UnitOfWork.Clear() is now session.ClearPending(). Dispose the session (await using).
  • IDocumentStoreProvider → folded into IDocumentSessionFactory (factory.GetStore(name) / factory.OpenSession(name)).
  • DocumentContext now wraps an IDocumentSession and is IAsyncDisposable/IDisposable (like DbContext); it exposes SaveChanges/BeginTransaction/Add and its generated constructor takes IDocumentSession. DocumentContext.CreateUnitOfWork() is gone (use the context’s own Add/SaveChanges, or context.Session). Typed DocumentSet<T> writes stay immediate.

Migration: store.CreateUnitOfWork()store.OpenSession(); add await using; uow.Clear()session.ClearPending(). Inject IDocumentSession (add AddScopedDocumentSession() in ASP.NET) or IDocumentSessionFactory where there’s no request scope. Replace IDocumentStoreProvider with IDocumentSessionFactory.

BREAKING

Telemetry is now embedded and always-on. The instrumentation decorator (InstrumentedDocumentStore), the AddDocumentStoreInstrumentation() extension methods, and the DocumentStoreOptions.Instrumentation flag were removed. Every store now emits OpenTelemetry metrics + trace spans directly, on every provider and every construction path — including a plain new …DocumentStore(options), which the decorator could never reach. It stays zero-cost when unobserved (ActivitySource.StartActivity returns null with no listener; the Meter instruments no-op with no subscriber), and signals from a named/keyed store carry a db.namespace tag so multiple stores stay distinguishable. Operation names and db.* tags are otherwise unchanged, so existing dashboards keep working. Migration: delete any AddDocumentStoreInstrumentation() calls and o.Instrumentation = true; just subscribe your OTel pipeline to .AddMeter("Shiny.DocumentDb") / .AddSource("Shiny.DocumentDb").

BREAKING

DI registration and diagnostics folded into the core package. Shiny.DocumentDb.Extensions.DependencyInjection (AddDocumentStore, AddDocumentContext, seeding, multi-tenancy) and Shiny.DocumentDb.Diagnostics (OpenTelemetry metrics + tracing) now ship inside the core Shiny.DocumentDb package. The types keep their Shiny.DocumentDb namespace, so code is source-compatible — just remove the two now-obsolete PackageReferences; your using Shiny.DocumentDb; and every AddDocumentStore call keep working. Core gains three lightweight, AOT-safe dependencies (Microsoft.Extensions.DependencyInjection.Abstractions, Microsoft.Extensions.Hosting.Abstractions, Microsoft.Extensions.Diagnostics). Telemetry is now always-on (see above), so there’s no separate instrumentation call or flag to wire up.

BREAKING

Interceptors — scoped DI just works, transaction-visible, and fires on every provider. Building on the session model, the interceptor scope machinery is gone and scoped services now work with no marker: IScopedDocumentInterceptor is removed (a plain IDocumentInterceptor gets ctx.Services), and the scoped-interceptor ban is liftedservices.AddScoped<IDocumentInterceptor, X>() is supported. Interceptors are resolved fresh from the flowing scope per write (a scoped session’s own request scope, or a per-unit fallback child scope for scope-less immediate writes), so scoped services resolve the caller’s own instances with no captive-singleton problem, and ctx.Services is now non-nullable. The hooks also get a transaction-bound view: ctx.Store and the new ctx.Session (an IDocumentSession on the current write’s transaction) both see this unit’s uncommitted rows and let a hook write side effects — ctx.Session.Add(outbox); await ctx.Session.SaveChanges(); flushes an outbox row into the same transaction, committing atomically with the triggering write (a single write with per-doc interceptors runs as an implicit one-op unit of work). Add Order to sequence interceptors deterministically. This also fixes a latent bug: DI-registered interceptors now fire on every provider — they previously silently never ran on MongoDB, CosmosDB, LiteDB, IndexedDB, DynamoDB, Azure Table, or in Orleans grain storage. Full transaction visibility is a relational + LiteDB guarantee; other backends follow their own model (see Interceptors). Migration: change : IScopedDocumentInterceptor to : IDocumentInterceptor; drop any ctx.Services == null checks and any singleton-plus-manual-child-scope workarounds you built for scoped dependencies.

BREAKING

Auto-embed is now a write interceptor — and OnBeforeInsert is removed. Shiny.DocumentDb.Extensions.AI’s AutoEmbedOnInsert<T> no longer rides a bespoke before-insert hook; it is a real IDocumentInterceptor. Three wins: it now fires on every provider (it previously silently never ran on the document-native stores — CosmosDB, MongoDB, Redis, Firestore, DynamoDB, Azure Table — which is exactly where vector search lives); it runs inside the write’s transaction; and a new DI overload resolves the IEmbeddingGenerator<string, Embedding<float>> per write from the caller’s scope (ctx.Services), so a scoped session picks the caller’s own generator (per-tenant / per-scope model selection just works). AutoEmbedOnInsert<T>(sourceSelector, targetSetter, targetGetter?) resolves the generator from DI; the existing AutoEmbedOnInsert<T>(generator, …) overload still takes a fixed instance for the container-free new DocumentStore(options) path. Removed (breaking — no shims): DocumentStoreOptions.OnBeforeInsert<T> and the private before-insert hook mechanism it fed (auto-embed was its only consumer). Migration: the explicit-generator AutoEmbedOnInsert call is unchanged; if you called OnBeforeInsert<T> directly to fill a computed field, switch to OnBeforeWrite<T> (an interceptor lambda over ctx.Document, which also keeps the field fresh on updates). See Vector › Auto-embed.

Feature

Four new providers — Redis, RavenDB, Google Firestore, and Amazon DocumentDB. All four are document-native / NoSQL backends implementing the full IDocumentStore surface (CRUD, LINQ + string query, batch, optimistic-concurrency CAS, in-process IObservableDocumentStore, unit-of-work, IDocumentMaintenance.ClearAll). Redis (AddRedisDocumentStore(...), requires Redis Stack — RedisJSON + RediSearch) stores each document as a RedisJSON key with a RediSearch index per type; MapIndexedProperty predicates push down to FT.SEARCH, it has native full-text/vector(KNN)/geo search and a keyspace-notification change feed, and it’s the one NoSQL provider where Int/Long Id auto-generation works (atomic INCR). RavenDB (AddRavenDbDocumentStore(...)) stores an opaque System.Text.Json envelope per document and evaluates LINQ client-side over immediately-consistent id-prefix streams, with ToQueryString rendering RQL. Google Firestore (AddFirestoreDocumentStore(...)) stores documents as native Firestore maps (broad single-field pushdown + a full-scan fallback for missing composite indexes), native cursor pagination (ToCursorPage), transaction-guarded CAS, and a real per-query change feed + IChangeFeedDocumentStore via native snapshot listeners. Amazon DocumentDB (AddDocumentDbDocumentStore(...)) is a thin MongoDB-provider subclass (TLS + retryWrites=false defaults) that down-flags the features DocumentDB lacks ($text full-text and Atlas $vectorSearch throw). Int/Long Id auto-generation is unsupported on Firestore and Amazon DocumentDB (use Guid/string or assign the Id).

Feature

Two new relational providers — MariaDB (Shiny.DocumentDb.MariaDb) and CockroachDB (Shiny.DocumentDb.CockroachDb) — both are thin, wire-compatible extensions of an existing provider, so they inherit the full document surface rather than re-implementing it. MariaDB extends the MySQL provider (same MySqlConnector driver): CRUD, LINQ-to-SQL, JSON indexes, batch/bulk, temporal history, computed columns, soundex and full-text all lower to identical dialect — new MariaDbDatabaseProvider("Server=…;Database=…;User=…;Password=…;"). It diverges in three documented places: spatial runs the portable envelope tier (bbox prune + in-process refine) because MariaDB’s ST_Distance is not metric for SRID-4326 geometry (a native distance query would return wrong metres); full-text drops the "a b"@N proximity operator (unimplemented in MariaDB boolean mode); and array-valued queries are unsupported — MariaDB has no JSON_TABLE (MDEV-16620) and no LATERAL, so predicates and projections that unnest a JSON array (Any/All over a collection, collection aggregates, GroupBy over an array element) throw a clear NotSupportedException at query-build time rather than emitting SQL that errors on the server. Scalar CRUD, filtering, ordering, projection, temporal, computed columns, full-text and backup all work as on MySQL. CockroachDB extends the PostgreSQL provider (same Npgsql driver): JSONB storage and operators, CRUD, ON-CONFLICT batch/upsert, read-merge-write patch, temporal, computed columns, partial JSON indexes and full-text search (tsvector + GIN + ts_rank, CockroachDB 23.1+) all work unchanged, and both spatial and vector search are native — PostGIS-compatible ST_* built-ins (no CREATE EXTENSION) and the pgvector-compatible VECTOR(n) type with the <->/<=>/<#> operators, so MapSpatialProperty and MapVectorProperty + NearestVectors both work unchanged (vector search runs brute-force — CockroachDB’s own CREATE VECTOR INDEX is v25.2+/L2-only). CockroachDB’s genuinely Postgres-only surfaces are scoped away: the LISTEN/NOTIFY change feed, native binary COPY bulk-copy, and soundex (fuzzystrmatch) report unsupported rather than emitting incompatible SQL — everything else, including bulk import via multi-row insert, works. Note CockroachDB’s SERIALIZABLE-by-default transactions may surface retryable (40001) errors under contention. Both providers are also wired into the Aspire integration (DocumentProviderKind.CockroachDb / .MariaDb — pass the kind explicitly to AsDocumentStore, since neither has a first-party Aspire hosting resource to auto-detect). See MariaDB and CockroachDB.

Feature

Full geometry spatial queries — the spatial feature grows from point-only to full OGC geometry. A new Geometry model (GeoLineString, GeoPolygon with holes, GeoMultiPoint, GeoMultiLineString, GeoMultiPolygon, GeoGeometryCollection; GeoPoint implicitly converts to a point geometry) serializes as GeoJSON in the document body, and MapSpatialProperty<T>(x => x.Area) now accepts a Geometry? property alongside the existing GeoPoint? overload. Query it with the Geo-prefixed topological predicate family — GeoIntersects, GeoContainedBy, GeoContains, GeoDisjoint, GeoTouches, GeoCrosses, GeoOverlaps, GeoEquals, GeoCovers, GeoCoveredBy — plus GeoWithinDistance(geometry, meters) for a distance band. Every predicate takes an optional orderByDistanceFrom (point or geometry) and returns SpatialResult<T> with DistanceMeters populated; NearestNeighbors now works over geometry-mapped types too. The Geometry model also exposes in-memory Area/Length/Perimeter/Centroid/NumPoints/NumGeometries (C# accessors — to filter by a measurement server-side, compute the scalar in your app and store it as a normal indexed field) and IsValid/IsSimple/MakeValid. Provider coverage — every SQL provider plus the document stores: SQLite via the R*Tree bbox prune + in-process relate/refine; PostgreSQL, MySQL, SQL Server, Oracle, and DuckDB via a dependency-free envelope-sidecar table (four indexed columns) + the same in-process refine — no PostGIS/geography/SDO_GEOMETRY/DuckDB-spatial extension required; CosmosDB pushes ST_INTERSECTS/ST_WITHIN/ST_DISTANCE down natively; MongoDB gains spatial via a 2dsphere index with native $geoIntersects/$geoWithin/$near. On Cosmos/Mongo the finer predicates refine over the intersect candidate set. GeoDisjoint is anti-selective and scans the type (O(n)) on the two-pass providers. The remaining fallback stores (LiteDB, IndexedDB, Azure Table, DynamoDB) stay SupportsSpatial => false. Existing GeoPoint mappings and WithinRadius/WithinBoundingBox/NearestNeighbors are unchanged. See Spatial.

Feature

Spatial predicates in LINQ via DocumentFunctions — compose a spatial predicate with the rest of a query (other Where clauses, OrderBy, Count, paging), all server-side: store.Query<Zone>().Where(z => DocumentFunctions.Intersects(z.Area!, area) && z.Active).OrderBy(z => DocumentFunctions.Distance(z.Area!, origin)). The family — Intersects, Disjoint, Contains, Within, Covers, CoveredBy, Touches, Crosses, Overlaps, GeoEquals, WithinDistance (in Where) and Distance (in OrderBy) — lowers to each engine’s native spatial function or a registered UDF. Every predicate lowers to the engine’s native spatial function (or a UDF on SQLite), backed by a real 2-D spatial index: SQLite R*Tree + docdb_st_* C# UDF (dependency-free); PostgreSQL GiST, MySQL SPATIAL, DuckDB R-Tree, SQL Server spatial index, Oracle SDO_GEOMETRY column + MDSYS spatial index (SDO_RELATE operators, requires Oracle Spatial) — each over a native geometry column in the sidecar populated on write (PostgreSQL needs PostGIS, DuckDB auto-loads spatial); CosmosDB ST_INTERSECTS/ST_WITHIN/ST_DISTANCE; MongoDB $geoIntersects/$geoWithin (2dsphere index, ensured on the LINQ path). CosmosDB/MongoDB expose the intersect/within/distance subset in a Where; the finer predicates throw there and are served by the dedicated store.Geo* methods (which support every predicate on every spatial-capable provider). WithinDistance in a Where needs a geodesic (metric) distance function, which SQL Server (planar geometry sidecar) and DuckDB (no polygon geodesic distance) don’t have — rather than silently approximating with planar degrees (wrong away from the equator), they throw NotSupportedException; use store.GeoWithinDistance(...), which refines with an exact Haversine distance in managed code and is correct on every provider. Set PortableSpatial = true on a relational provider to force the dependency-free envelope tier. The same geo functions also work in the string-expression surface — Where("…"), interpolated Where($"…"), OrderBy("…"), Project("…") — with the query geometry supplied as an interpolated {value} (a bound Geometry/GeoPoint) or an inline GeoJSON string literal. See Spatial.

Feature

Grouped aggregation — GroupBy / Having / IGroupedDocumentQuery — roll a filtered set up into one row per key, on top of the existing aggregate engine. store.Query<Order>().GroupBy(o => o.Status).Select(g => new StatusRollup { Status = g.Key, Count = g.Count(), Revenue = g.Sum(o => o.Total) }) groups on a JSON property, with g.Key for the group value and the Sql group aggregates g.Count() / g.Sum / g.Avg / g.Min / g.Max over the members. Aggregates extract their argument with the member’s CLR type, so Sum/Avg of a decimal keep full scale (no float round-off — exact on every provider with a native decimal type; SQLite aggregates as REAL) and Min/Max work over dates and strings, not just numbers. Keys may be nested (o => o.Address.Country), derived (o => o.CreatedAt.Month — a “by month” rollup with no stored column), or an anonymous type for a multi-column key (new { o.Status, o.Region }g.Key.Status / g.Key.Region). Having(g => g.Sum(o => o.Total) > 10_000) filters groups by an aggregate (SQL HAVING); grouped results are ordinary rows, so OrderBy (over an output column), Paginate, Count (number of groups) and ToQueryString all flow through. String-grammar parity: GroupBy("status").Having("sum(total) > 10000").Project("status, count() as orders, sum(total) as revenue") lowers to the same SQL. The whole-set Count/Sum/Average terminals remain for ungrouped totals. Provider tier: push-down on the relational providers (SQLite, SQLCipher, PostgreSQL, MySQL, SQL Server, Oracle, DuckDB) — GROUP BY + HAVING + grouped ORDER BY + multi/derived keys; MongoDB, Cosmos, LiteDB and IndexedDB group client-side (the filtered set is read and aggregated in memory, like their Select), typed surface only; Azure Table and DynamoDB throw NotSupportedException (key-partitioned, no silent scan). See Querying → Grouping & aggregation.

Feature

Cursor / keyset pagination — ToCursorPage / CursorPage<T> / ToCursorStream — a forward-only, seek-based alternative to offset paging that stays O(log n) per page (with an index over the sort key) and is stable under concurrent writes. store.Query<Order>().Where(o => o.Status == "open").OrderByDescending(o => o.CreatedAt).ToCursorPage(cursor, take: 50) returns a CursorPage<T> of Items plus an opaque NextCursor (null ⇒ last page); the keyset is derived from the query’s own OrderBy with an Id tiebreaker appended automatically so the order is always total. Pass null for the first page and hand the previous NextCursor back for each subsequent page — no ever-growing OFFSET, and no COUNT(*) round-trip. ToCursorStream(pageSize) walks every page as an IAsyncEnumerable<T> — a resumable full scan that never pays deep-offset cost. NULLs in an ordered column are handled correctly — they sort last (both ascending and descending, consistently across every provider) and the keyset seek carries across the NULL boundary rather than dropping the rest of the set. A cursor is valid only for the exact ordering that produced it (a shape hash over the OrderBy throws InvalidOperationException on the common “wrong sort” reuse), filters and sort must be stable across calls, and it is not valid after Select/Project/GroupBy (throws NotSupportedException); take must be > 0 and ≤ 10,000. Offset paging (Paginate / PageResult) stays for page-number UIs and totals — the two coexist and the docs steer you to the right one. Provider tier: keyset push-down on every relational provider (SQLite, SQLCipher, PostgreSQL, MySQL, SQL Server, Oracle, DuckDB); LiteDB, IndexedDB and MongoDB page the keyset client-side (same stable contract, without the server-side seek); Cosmos, DynamoDB and Azure Table don’t opt in yet and throw NotSupportedException (native continuation-token support is a follow-up). See Querying → Pagination.

Feature

Composable full-text with Lucene syntax — DocumentFunctions.LuceneMatch / LuceneScore — full-text as a composable predicate rather than a separate ranked call. DocumentFunctions.LuceneMatch(a.Body, "orleans AND grain NOT deprecated") goes inside a Where (AND/OR with ordinary predicates, paging, etc.) and DocumentFunctions.LuceneScore(a.Body, "orleans grain") inside an OrderBy/projection, both translating a Lucene query — terms, "phrases", AND/OR/NOT (&&/||/!, +/-), ( grouping ), prefix foo*, fuzzy foo~, proximity "a b"~5, boost foo^2 — to the provider’s native full-text engine over the existing MapFullTextProperty index (the type must still be mapped). Also in the string grammar: Where("lucenematch(body, 'title:quick AND brown~')"), OrderBy("lucenescore(body, 'orleans') desc"), interpolated Where($"lucenematch(body, {q})"). Provider tier: composable match+score on SQLite (FTS5), PostgreSQL (tsquery), MySQL (BOOLEAN MODE), SQL Server (CONTAINS); Oracle Text is match-only (SCORE(n) needs a co-located label — use FullTextSearch for ranking); LiteDB/IndexedDB run the full grammar in-memory (including fuzzy). DuckDB, CosmosDB and MongoDB don’t support composable queries (their engines can’t express the boolean grammar inline) — use store.FullTextSearch(...) there. Operators a backend can’t express throw NotSupportedException (never silent degradation); field-scoped terms (title:foo) are not supported in v1 on any provider. See Full-Text Search.

Feature

Scope-aware multi-tenancy and temporal audit actor. Building on the session model, both ambient accessors now resolve from the caller’s session DI scope instead of the root container. Multi-tenancy: a request-scoped ITenantResolver (services.AddScoped<ITenantResolver, …>()) now resolves the request’s own tenant when writing/reading through a scoped IDocumentSession — no more relying on an ambient IHttpContextAccessor. Temporal: new TemporalOptions.ResolveActor (Func<IServiceProvider, string?>) captures “who made this change” from a request-scoped ICurrentUser per unit of work — o.MapTemporal<Order>(t => t.ResolveActor = sp => sp.GetService<ICurrentUser>()?.Id). It takes precedence over the unscoped CaptureActor.

Feature

Consistent-read sessions. IDocumentSession.BeginTransaction(IsolationLevel) opens an explicit transaction at a chosen isolation level — e.g. RepeatableRead/Snapshot so every read in the session sees one consistent view (read-modify-write and multi-read consistency without app-level CAS loops). Relational providers only; combine with LockMode.Update for pessimistic locking reads.

Feature

Unit-of-work telemetry. An IDocumentSession now emits a <system>.unit_of_work parent span (tagged db.session.id), so every operation in a unit of work nests under it and shares one trace — a unit of work reads as one correlated subtree instead of flat sibling spans. SaveChanges records a new db.client.unit_of_work.operations histogram (buffered writes flushed per commit) for write-batching insight. Both are zero-cost when unobserved; subscribe via .AddSource("Shiny.DocumentDb") / .AddMeter("Shiny.DocumentDb").

Feature

Structured ILogger logging on every provider. When a store is created from the container (via AddDocumentStore, a provider’s Add…DocumentStore, or the provider’s IServiceProvider constructor) and an ILoggerFactory is registered, every SQL / operation statement is now logged through ILogger at Debug under the Shiny.DocumentDb category (plus a one-time store-initialized line) — so store activity flows into Serilog / OpenTelemetry logs / Application Insights with a proper category and level instead of only the loose options.Logging string callback (which still fires, composed alongside). Works across the relational core and all six non-relational providers (SQLite/…/Oracle, MongoDB, Cosmos, LiteDB, IndexedDB, DynamoDB, Azure Table). Control the volume with Logging:LogLevel:Shiny.DocumentDb. Nothing changes on the container-free new …DocumentStore(options) path.

Feature

Backup export/restore preserves CreatedAt / UpdatedAt (envelope v2). A backup round trip used to rewrite every document’s creation and modification timestamps to the import time. The export now records them and the Insert restore path binds them per row across every provider (relational + MongoDB/CosmosDB), so a backup faithfully preserves document history. The format is backward-compatible: a v1 backup (no timestamps) still imports, stamping the current time. DuckDB/PostgreSQL/SQL Server fall back from their native bulk-copy fast path to the multi-row insert only when timestamps are present.

Feature

Vector search on IDocumentSession. NearestVectors<T> now lives on IDocumentSession as well as IDocumentStore, so a unit of work can run ANN search next to its buffered writes. Inside an explicit transaction the session’s vector search reads that transaction’s consistent snapshot (relational providers), so a read-modify-write over vector hits sees one stable view. It delegates to the same provider engine, and the fluent store.Query<T>().NearestVectors(…) path is unchanged. (The SupportsVector capability stays a store property — reach it via session.Store.SupportsVector.) See Vector › Queries.

Enhancement

Merge-vs-replace flags on Update and Upsert — pick the write mode explicitly without switching methods. Update(doc, patch: false) is the existing full replace; Update(doc, patch: true) RFC 7396 deep-merges into the existing document (must already exist). Upsert(doc, patchIfUpdate: true) is the existing merge-or-insert; Upsert(doc, patchIfUpdate: false) replaces the body wholesale on update and inserts if absent. Same flags on the late-bound JSON lane — Update(type, jsonObject, patch: true) / Upsert(type, jsonObject, patchIfUpdate: false) — which is the precise way to do partial updates (a typed object always serializes every property, so patch: true on it only skips null fields; a partial JsonObject changes exactly the keys it carries). The merge modes strip null properties (unset fields are left unchanged, never deleted — a null in a patch is a no-op, not an RFC 7396 key deletion). The opt-in merge/replace modes read-modify-write inside a transaction with a row-locking SELECT (FOR UPDATE / UPDLOCK) on every relational provider, so concurrent partial writes to the same document don’t lose updates. Provider tier: the flags are implemented on the relational providers (SQLite, SQLCipher, MySQL, SQL Server, PostgreSQL, Oracle, DuckDB) and the JSON lane; the two default behaviours (Update replace, Upsert merge) work on every store, while the non-default modes throw NotSupportedException on the document-native and key-partitioned stores. See CRUD.

Fix

Query correctness — LIKE escaping, numeric ordering, and != null semantics. Contains/StartsWith/EndsWith now escape %, _ (and [ on SQL Server) in the search term and emit an ESCAPE clause, so Contains("10%") matches the literal "10%" rather than any string starting with 10 — and the result now matches the in-memory providers’ literal String.Contains. OrderBy on a numeric property now extracts it as its typed value, so it sorts numerically (5, 25, 100) instead of lexicographically (100, 25, 5) on PostgreSQL/MySQL/SQL Server. A field != value predicate now includes rows where the field is null/absent (C# semantics), instead of dropping them via SQL three-valued logic. Projection sub-query constants (Count(pred)/Any(pred) inside a Select) are now normalized (dates/decimals/enums/guids) the same way top-level Where constants are. The OData $filter eq null/ne null against a non-nullable value-type property no longer throws.

Fix

String-grammar parity — pow, two-argument round, and concat. The string-expression surface (Where/OrderBy/Project) gained pow(x, y), round(x, digits) and concat(a, b, …), matching their LINQ (Math.Pow / Math.Round(x, n) / string +) equivalents.

Fix

Enum queries work when enums are stored as strings. With a JsonStringEnumConverter (or a [JsonConverter] that emits strings) configured, enums are persisted as their member name — but the relational query pipeline always bound an enum == / != / in comparison as the underlying number and cast the extracted field to an integer, so every enum predicate silently matched nothing. The lowerer now detects a string-stored enum and binds the exact member-name string the write path persisted (extracting the field as text), across both the typed LINQ surface and the string grammar (Where("Level == 'High'")). Numeric-stored enums (the default) are unchanged. The fix spans the relational providers, CosmosDB (shared lowerer), and MongoDB (its own translator, fixed to match) for equality and in comparisons.

Fix

Vector search — score direction, metric gating, and provider-specific semantics. VectorResult.Score for the DotProduct metric is now the raw inner product (higher = closer) on every relational provider, as documented, rather than the database’s negated distance value (result ordering was already correct). Metrics a backend can’t express now fail loudly at mapping time instead of emitting SQL that errors on the server: DotProduct on SQLite (sqlite-vec supports only Cosine/Euclidean) and Hamming on PostgreSQL/CockroachDB (pgvector Hamming needs a bit(n) column) both throw NotSupportedException. MongoDB vector queries with a post-filter now widen the candidate pool so a filtered search doesn’t return fewer than k. The Score value’s meaning is provider-specific: MongoDB/CosmosDB report a similarity (higher = closer) for all metrics while the relational providers report a distance (lower = closer, except DotProduct) — the same metric, opposite direction, with no lossless canonical conversion. Results are always ordered nearest-first regardless of provider, so rely on ordering, not the raw score, for portable ranking.

Fix

Mapped-property JSON names now honor [JsonPropertyName] and source-generated contexts. The version (MapVersionProperty), spatial, vector, computed, and full-text mappings resolved their stored JSON path from the runtime naming policy alone, which ignores the effective JSON name baked into a JsonTypeInfo. If a mapped property carried a [JsonPropertyName("…")] (or a source-gen context baked a different name), the resolver pointed at a key that isn’t in the document — most visibly, optimistic-concurrency writes on such a type failed with a phantom ConcurrencyException because the CAS predicate read a missing path. The mappings now read the effective name off the resolved JsonTypeInfo, falling back to the naming policy only when the type has no metadata.

Fix

Generated serialization honors [JsonPropertyName] under trimming/AOT. A DocumentSerialization.Generated type with a [JsonPropertyName] on a queried property could emit the wrong JSON path once trimming removed the property’s reflection metadata, silently returning empty results. The generator now roots each document type’s public properties ([DynamicDependency]) so the query layer resolves the mapped name correctly after trimming.

Fix

DocumentSerialization.Generated now diagnoses silent data loss. Types that the generated (AOT) metadata resolver can’t round-trip no longer serialize as an empty {} — the source generator now raises DDB005 for a property whose type is a collection other than List<T>/T[] (e.g. Dictionary, HashSet, ObservableCollection) and for an init-only or non-public-setter property that would be silently dropped. Fix the model (use List<T>/array, add a public setter, or [JsonIgnore]) or switch that type’s DocumentSerialization to Reflection/JsonContext/Auto.

Fix

Temporal history is now tenant-isolated. The _history sidecar gained a TenantId column and every temporal read/write (History / AsOf / AsOfAll / ChangesByActor / ChangesBetween / Restore / GetDiffBetween) is scoped to the current tenant. Previously a tenant could read another tenant’s version history via a known id, and AsOfAll returned every tenant’s documents. (Applies to newly-created history tables.)

Fix

Batch & unit-of-work writes now maintain the tenant column and all sidecars. BatchInsert previously used a fast path that skipped the TenantId column (making batch-inserted rows invisible and un-deletable per tenant) and the spatial R-tree index (making them un-findable by spatial queries). Batch inserts of tenant-scoped, spatial, vector, temporal, filtered or intercepted types now route through the per-document path, which handles all of that inside one transaction. Relatedly, unit-of-work writes (UnitOfWork / the atomic batch fallbacks) now maintain the spatial and vector sidecars (previously only relational + temporal), and a batch/UoW insert of a vector type runs its auto-embed hook. Clear<T> with a query filter now purges the spatial/vector index rows of the deleted documents, and ClearAll no longer errors on SQLite FTS5 shadow tables.

Fix

Concurrent int/long auto-generated ids no longer collide. Auto-generated int/long ids come from SELECT MAX(Id)+1, which raced on pooled providers (two concurrent inserts picking the same id, one failing with a duplicate-key error). Insert now linear-probes the next free id on a collision, so concurrent inserts converge on distinct ids. Relatedly, a failed optimistic-concurrency Update now restores the caller’s in-memory version instead of leaving it bumped.

Fix

IndexedDB inserts and version updates are now atomic. On Blazor WASM, Insert’s duplicate check and Update’s optimistic-concurrency check ran a JS get and put in separate transactions, so two overlapping writes could both pass their check. Both now run get-check-put inside a single readwrite transaction. (Upsert’s RFC 7396 deep-merge stays on the C# path.)

Fix

BulkWriteMode.Merge works on every relational provider, and change-feed robustness. RestoreAsync/BulkImportAsync with Mode = Merge no longer throws on PostgreSQL/MySQL/MariaDB/CockroachDB/SQL Server/Oracle — it falls back to a per-row merge (SQLite/DuckDB keep the native multi-row path). Change notifications on the LiteDB/Azure Table/DynamoDB stores are now buffered per async flow (AsyncLocal) so concurrent units of work and direct writes can’t cross-contaminate each other’s events. The PostgreSQL change-feed trigger is re-provisioned atomically (CREATE OR REPLACE TRIGGER, no drop window that loses events), a faulted change handler tears the subscription down instead of leaking into an unbounded queue, and the SQL Server poll bounds each read to the current snapshot version so a write racing the poll isn’t delivered twice.

Fix

Change-feed concurrency hardening. Fixed three latent races in the native change-feed / streams paths: DynamoDB’s lazy IAmazonDynamoDBStreams client is now built under a double-checked lock (concurrent SubscribeChanges no longer each build and leak a client); Cosmos’s container-init fast path uses a ConcurrentDictionary instead of a lock-free HashSet read racing Add/Clear; and SQL Server’s process-global SqlDependency.Start/Stop is now reference-counted per connection string, so a second subscription’s disposal can no longer tear down the shared Service Broker pump a first, still-live subscription depends on.

Fix

DynamoDB Streams change feed: no missed records across shard splits. A newly-split child shard was subscribed at LATEST, dropping any records written to it before the next shard refresh; child shards now start at TRIM_HORIZON and are not read until their parent shard has fully drained, preserving per-key ordering across a split. (Also removed a dead no-op branch in the poll loop.)

Fix

DuckDB bulk import no longer throws on a multi-tenant store. An Insert-mode BulkImport/Restore into a shared-table multi-tenancy DuckDB store threw a column-count mismatch (… has 6 columns but you specified only 5 values) because DuckDB’s native appender is positional and doesn’t account for the trailing TenantId column. DuckDB now falls back to the multi-row INSERT path when tenancy is enabled — leaving TenantId NULL, exactly like the PostgreSQL and SQL Server native bulk paths. (Tenant-scoped bulk import is still documented as unsupported; the imported rows are NULL-tenant — the fix is graceful parity rather than a hard failure.)

Fix

Backup accounting and error shape. DocumentsWritten/DocumentsSkipped are now derived from row intent for Replace/Merge (MySQL’s ON DUPLICATE KEY UPDATE counts an update as two rows, which could make DocumentsSkipped negative). The native bulk-copy path now wraps a duplicate-key collision in the same friendly InvalidOperationException as the multi-row path instead of leaking the raw provider exception.

Fix

Assorted robustness fixes. Cursor pagination raises the documented InvalidOperationException (not a raw FormatException) for a tampered cursor value; a malformed Lucene number (e.g. foo~1.2.3) raises the parser’s positioned error; a pure-negation Lucene query (-x) is now rejected in-memory too, matching the relational engines; RFC 6901 JSON-pointer escaping (~0/~1) is applied in GetDiff/patch-application; the RFC-7396 patch null no-op (not a key delete) is documented; the WKT writer closes polygon rings (SQL Server ingestion no longer rejects an unclosed ring); spatial envelopes handle antimeridian-crossing geometries; Covers no longer false-positives on a concave cover cutting an inner edge. Cosmos DB Orleans grain storage now percent-encodes the reserved / in grain ids (state was previously unreadable after a write). IndexedDB single-document writes resolve on transaction commit (surfacing quota/abort failures). The Cockroach soundex capability now fails loudly. Change-feed subscriptions no longer leak their CancellationTokenSource on a faulted handler, and normal stream cancellation is no longer recorded as a telemetry error.

Fix

Nullable spatial locations no longer throwMapSpatialProperty<T> now accepts a nullable GeoPoint? property (both the expression and the AOT-safe (propertyName, accessor) overloads), and a document whose mapped location is null is skipped by the spatial index instead of throwing. Previously, inserting or updating a document with a null location threw a NullReferenceException on the write path (the R*Tree sync called the accessor unconditionally) — the common case for records where coordinates are optional (e.g. a calendar event that may not have a place). Setting a previously-populated location back to null on update now also purges the stale index row, so the document stops matching WithinRadius/WithinBoundingBox/NearestNeighbors. The write-path crash affected the relational R*Tree provider (SQLite); the mapping signature and read-path guards are applied to CosmosDB for consistency. Existing non-nullable GeoPoint mappings compile unchanged. See Spatial.

Enhancement

Instrumentation = true DI flag for OpenTelemetryShiny.DocumentDb.Extensions.DependencyInjection now references the Diagnostics decorator, so you can opt into metrics + tracing with a single option instead of a separate AddDocumentStoreInstrumentation() call: services.AddDocumentStore(o => { o.DatabaseProvider = …; o.Instrumentation = true; }). It’s equivalent to calling the extension after registration and still requires you to subscribe your OTel pipeline to the Shiny.DocumentDb meter/source. Honored by the non-keyed AddDocumentStore overloads (including multi-tenant); setting it on a keyed/named store throws NotSupportedException, since the decorator only wraps the non-keyed registration. Registering through the DI package now pulls the (lightweight, in-box) diagnostics dependencies; bare new DocumentStore(options) usage stays dependency-free. See Telemetry & Diagnostics.

Feature

Late-bound JSON lane (Type + JsonNode) — write and read documents by handing the store a registered document Type and the JSON body directly, without a CLR T. The exact tool for generic HTTP intake, message-bus payloads, ETL, and gateways where you hold the payload as JSON. Writes: store.Insert(type, node) / Update / Upsert accept a JsonObject (one document) or a JsonArray (many, written atomically in one transaction — a mid-batch failure rolls the whole call back) and return the number written; a primitive JsonValue throws. The body is stored AS-IS (property names must match the type’s serialized shape, camelCase by default), so serialization is skipped entirely. Unlike IDocumentBackup/BulkImport, this lane rides the full normal write pipeline — tenancy, temporal history, versioning/optimistic-CAS, spatial + vector sidecars, JSON interceptors, and change notifications all apply; the generated Id (and bumped version) is injected back onto your node exactly like the typed Insert<T>. Because the body is verbatim, every registered spatial/vector mapping’s JSON path must be present — an absent path throws InvalidOperationException naming it, while an explicit JSON null is honored as a deliberate “no value” (the sidecar is skipped). Upsert runs that presence check only when the element carries no Id (a guaranteed insert), so partial RFC 7396 merge patches aren’t forced to re-send the location/vector. Reads: store.Get(type, id) returns the raw document as a JsonNode?, and store.Query(type, whereClause, parameters) / QueryStream(...) run the same string WHERE surface as Query<T>(string) but hand back JsonNodes with no deserialize to T — cheaper than the typed read and AOT-clean (no JsonTypeInfo<T> needed at all). Filtering reuses the existing WHERE/OData string; there is deliberately no “filter by JsonNode”. Two documented limitations: object-mutating interceptors are a no-op on this lane (there is no T to mutate — JSON-shaped interceptors via ctx.GetJsonDocument() still work fully), and vector auto-embedding does not run (supply the embedding in the JSON). Provider tier: supported on the relational providers (SQLite, SQLCipher, MySQL, SQL Server, PostgreSQL, Oracle, DuckDB) via the core store; the document-native (MongoDB, Cosmos, LiteDB, IndexedDB) and key-partitioned (Azure Table, DynamoDB) providers throw NotSupportedException until a later cut, and the lane is not available inside a UnitOfWork. See CRUD.

Feature

Azure Table Storage provider (Shiny.DocumentDb.AzureTable) — a schema-free document store over Azure Table Storage (and the Cosmos DB Table API, same SDK) built on Azure.Data.Tables. It’s a NoSQL key-partitioned store: the library’s (typeName, id) identity maps to PartitionKey = typeName / RowKey = id, one table holds every type, and Query<T>() is always a single-partition read. Register with services.AddAzureTableDocumentStore(o => { o.ConnectionString = …; o.TableName = "Documents"; }) (connection string, SAS, shared key, or ServiceUri + DefaultAzureCredential/TokenCredential) — it wires IDocumentStore + IDocumentMaintenance. Full CRUD, batch (native SubmitTransaction in ≤100-item waves per partition), RFC 7396 merge Upsert, typed Query<T>(), Count/Clear/Remove, compensating CreateUnitOfWork(), and ETag-backed optimistic concurrency via MapVersionProperty<T> (ConcurrencyException on conflict). Rich LINQ queries evaluate client-side (the LiteDB ExpressionInterpreter model) after loading the type’s partition. Promote hot query paths with MapIndexedProperty<T>(x => x.Status) — the scalar is written as a native top-level column and LINQ predicates over it (plus the OData string Query/QueryStream/Count overloads and ToQueryString) push down into a server-side $filter to shrink the candidate set (the full predicate still re-runs client-side, so results are always exact). In-process change observation ships too — IObservableDocumentStore.NotifyOnChange<T> and Query<T>().NotifyOnChange(). Int/Long Id auto-generation is unsupported (no cheap MAX — use Guid/string, or assign explicitly); no spatial/vector/full-text/temporal. A body over the ~64 KB per-property cap throws a clear NotSupportedException. See Azure Table Storage.

Feature

Amazon DynamoDB provider (Shiny.DocumentDb.DynamoDb) — a schema-free document store over DynamoDB built on AWSSDK.DynamoDBv2. Same NoSQL key-partitioned model: (typeName, id) maps to partition key pk = typeName (HASH) and sort key sk = id (RANGE) in one table. Register with services.AddDynamoDbDocumentStore(o => { o.TableName = "Documents"; o.Region = RegionEndpoint.USEast1; }) (standard AWS credential chain, explicit Credentials, or ServiceUrl for DynamoDB Local; AutoCreateTable for dev; ConsistentRead toggles strongly-consistent reads) — it wires IDocumentStore + IDocumentMaintenance. Full CRUD, batch (native BatchWriteItem in ≤25-item waves with UnprocessedItems retry), RFC 7396 merge Upsert, typed Query<T>(), Count/Clear/Remove, compensating CreateUnitOfWork(), and conditional-write optimistic concurrency via MapVersionProperty<T> (a top-level Version attribute guard → ConcurrencyException). Same client-side query tier as Azure Table with the same MapIndexedProperty<T> promotion — predicates over promoted attributes push down into a FilterExpression, and the string Query/QueryStream/Count/ToQueryString overloads speak PartiQL over them. Ships both change surfaces: in-process IObservableDocumentStore.NotifyOnChange<T> and a native DynamoDB Streams–backed IChangeFeedDocumentStore.SubscribeChanges<T> (the table’s stream is enabled automatically on create) that observes inserts/updates/deletes from any writer. Same limitations as Azure Table (Int/Long auto-gen unsupported; no spatial/vector/full-text/temporal; 400 KB item cap throws a clear error). See Amazon DynamoDB.

Feature

Strongly-typed DocumentContext — an optional, EF-Core-style typed front-end over IDocumentStore. Declare your aggregates once on a partial context with [Document(typeof(User), Id = nameof(User.Email), JsonContext = typeof(AppJsonContext))], and the source generator bundled inside Shiny.DocumentDb (an analyzer under analyzers/dotnet/cs — no separate package to install) source-generates a DocumentSet<T> property per type, a ConfigureModel lowering (table/id mappings + JSON resolver wiring), and two DI extensions: services.AddAppContext(...) registers the context scoped (ASP.NET Core request scopes), and services.AddAppContextFactory(...) registers a singleton IDocumentContextFactory<AppContext> whose Create() news up a short-lived context on demand — the MAUI / Blazor / desktop story, mirroring EF Core’s AddDbContextFactory/IDbContextFactory<T> for apps with no ambient DI scope (inject the factory anywhere, even into singletons). Each context’s store is now keyed by the context type, so multiple DocumentContext types coexist in one container without shadowing each other on a single IDocumentStore (the first registered context stays the default un-keyed IDocumentStore for the extension packages that inject it). You then work model-first — await db.Users.Where(u => u.Age >= 18).ToList(), await db.Users.Insert(user) — with the JsonTypeInfo<T> threaded automatically, so you never re-type <T> or pass type metadata. DocumentContext/DocumentSet<T> ship in core and only need an IDocumentStore, so they work over all providers; the generated ConfigureModel/DI sugar targets the relational DocumentStoreOptions (for LiteDB/Mongo/Cosmos, build that store yourself and pass it to the context). It’s an ergonomics + discoverability layer only — no change tracking, identity map, or navigation/Include; writes are immediate (group them with CreateUnitOfWork()). Serialization is a per-type knob via [Document(..., Serialization = ...)]: JsonContext (point at your JsonSerializerContext — recommended, AOT-safe), Auto (inherit the store’s resolver, else reflection fallback), Reflection (explicit non-AOT opt-out), and Generated — where the generator emits the metadata-mode JsonTypeInfo (and its whole reachable type closure) itself via JsonMetadataServices, giving AOT-safe serialization from the single [Document] declaration with no hand-written JsonSerializerContext. Generated supports POCOs with a parameterless constructor and settable public properties of JSON primitives, Guid/date-time types, enums, nullable value types, nested objects, List<T>, and arrays (honoring [JsonPropertyName]/[JsonIgnore]); types outside that subset (records, parameterized/init-only constructors, dictionaries) raise DDB005 directing you to JsonContext. The generator reports clear diagnostics for misuse (DDB001 not partial, DDB002 doesn’t derive DocumentContext, DDB003 duplicate set name, DDB005 unsupported Generated type). See Typed Context.

Feature

Computed properties — map a value derived from other fields (Total = Quantity * UnitPrice, FullName = First + " " + Last, a normalized lower(Email)) that you filter, sort, and project by exactly like a stored property, even though it’s never written into the document JSON. Register it with MapComputedProperty<T>(o => o.Total, o => o.Quantity * o.UnitPrice) — the first expression is the [JsonIgnore] property it backs, the second is the definition — then reference it by name in typed LINQ (Where(o => o.Total > 100)), the string API (Where("total > 100").OrderBy("fullName")), Project("fullName as name, total"), and OData ($filter/$orderby/$select). By default it runs in alias mode: the definition is translated to SQL and inlined wherever the property appears — zero schema changes, every relational provider. Pass indexed: true and the relational providers materialize it as a native generated/computed column + index so filters/sorts are index-served — VIRTUAL generated column (SQLite, MySQL), STORED generated column (PostgreSQL), PERSISTED computed column (SQL Server), virtual column (Oracle) — engine-maintained, so it stays correct through every write (DuckDB can’t add a generated column via ALTER, so it uses alias mode). The value is recomputed and written back onto the object on read, so a round-tripped document is complete despite never being stored. LiteDB and IndexedDB evaluate it in memory (full filter/sort/project/read-back client-side); MongoDB and Cosmos support read-back and projection (server-side filter/sort by a computed property is not translated — filter on the underlying stored fields there). Definitions support JSON field access, string concatenation, the scalar functions, and numeric arithmetic (+ - * /, newly added to the query pipeline and available in ordinary Where clauses too). Fully Native-AOT/trim-safe — the definition is tree-walked to SQL and interpreted for read-back, never compiled.

Feature

Streaming bulk export / import / restore (IDocumentBackup) — move a whole store’s contents in and out as a portable, streamed v1 backup document. It’s a separate store capability (probe with store is IDocumentBackup, like IDocumentMaintenance — not on IDocumentStore), implemented by the relational DocumentStore (every SQL provider), MongoDB, and Cosmos DB. Three methods: ExportAsync(Stream, BackupExportOptions?) writes the store out as a JSON array of { id, docType, data } records (the document body emitted as-is; DocTypes filters which types, Indented pretty-prints); RestoreAsync(Stream, BulkRestoreOptions?) streams a backup back in with a forward-only reader so a multi-GB file never lands fully in memory; and the lower-level BulkImportAsync(IAsyncEnumerable<RawDocument>, …) feeds pre-shaped raw rows (RawDocument(string Id, string DocType, ReadOnlyMemory<byte> Data)) from any source — RestoreAsync is just the JSON adapter over it. Bodies are bound verbatim: no <T>, no JsonTypeInfo, no reflection over the documents (AOT-friendly). BulkWriteMode picks the collision strategy — Insert (fail on duplicate Id; fastest; multi-row VALUES everywhere, native bulk copy where available), Replace (overwrite the body wholesale), Merge (RFC 7396 deep-merge, same semantics as BatchUpsert), and SkipExisting (insert new, silently skip existing). BulkRestoreOptions adds ClearExistingFirst, ChunkSize (default 500), SingleTransaction (false = commit per chunk: resumable, bounded WAL/log), and an IProgress<BulkProgress> callback; the result is BulkRestoreResult(DocumentsRead, DocumentsWritten, DocumentsSkipped, ChunksCommitted). The import path deliberately skips versioning/CAS, temporal history, interceptors, tenant scoping, and global query filters — that’s where the speed comes from, so treat it as a raw restore lane, not a replacement for BatchUpsert. Provider tiers: Insert works on every provider; Replace & SkipExisting on all relational providers (ON CONFLICT on SQLite/DuckDB/PostgreSQL, ON DUPLICATE KEY/INSERT IGNORE on MySQL, MERGE on SQL Server & Oracle) plus Mongo/Cosmos; Merge only on SQLite, DuckDB and Mongo/Cosmos (it throws NotSupportedException on PostgreSQL/MySQL/SQL Server/Oracle — use Replace there). A native bulk-copy fast path makes Insert 10-100× faster on PostgreSQL (binary COPY), SQL Server (SqlBulkCopy), and DuckDB (appender). Caveats: Mongo/Cosmos imports are best-effort, not atomic (SingleTransaction is ignored); Oracle Replace/SkipExisting build the MERGE source via SELECT … FROM DUAL UNION ALL, which can reject documents above the VARCHAR2 bind limit (bound as CLOB); Cosmos export covers the whole database (all containers) while relational export covers the store’s configured tables. For a full restore prefer Insert or Replace — under Merge, a null in a body deletes that field (RFC 7396).

Feature

Full-text search across every provider — find documents by relevance, not substring. Register a searchable property with MapFullTextProperty<T>(a => a.Body) (or several fields combined, [a => a.Title, a => a.Body]), then query with store.FullTextSearch<T>("orleans persistence") or the fluent store.Query<T>().Where(...).FullTextMatch("..."). Results come back ranked — IReadOnlyList<FullTextResult<T>> with a Document and a normalized Score (higher = more relevant) — with an optional pre-filter predicate for tenant/category scoping. Each backend runs its native engine and the library creates the index for you at startup: FTS5 (SQLite), generated tsvector + GIN (PostgreSQL), generated column + FULLTEXT (MySQL), Oracle Text CTXSYS.CONTEXT, Full-Text Index + FREETEXTTABLE (SQL Server), the fts extension (DuckDB), full-text policy + FullTextScore (Cosmos DB), and a $text index (MongoDB); LiteDB and IndexedDB fall back to an in-memory TF-IDF scan so the API works everywhere. The index is engine-maintained — Insert/Update/Remove/Clear keep it in sync with no write-path bookkeeping. Full-text is declarative: a type must be mapped before it can be searched (that’s what lets the index be provisioned), and engines with a single full-text index per table (SQL Server, MongoDB) support one mapped type per table/collection. Oracle Text and SQL Server Full-Text Search are optional server components that must be installed for those providers. Cosmos full-text requires Microsoft.Azure.Cosmos 3.61.0+ (the provider’s pinned version was bumped accordingly).

Feature

OData governance — result caps, allowlists & complexity limitsShiny.DocumentDb.AspNetCore.OData endpoints can now be locked down with an ODataQueryPolicy per entity set, the security surface a public OData API needs. Set API-wide defaults with ConfigureDefaultPolicy(...) and override per set via the new EntitySet<T>(name, policy => …) overload (the override clones the defaults). Controls: DefaultPageSize (applied when $top is omitted, so reads are never unbounded) and MaxTop/MaxSkip; MaxFilterNodeCount/MaxOrderByNodeCount to reject pathologically complex queries; AllowFilter/AllowOrderBy/AllowSelect/AllowCount and AllowArithmetic to disable whole options; an AllowedFunctions allowlist; and FilterableProperties/SortableProperties/SelectableProperties per-property allowlists (matched on the root path segment). A disallowed-but-well-formed request is rejected with 400 and a message naming the offender; the effective page size is clamped to MaxTop. Defaults are fully permissive, so existing endpoints are unchanged until you opt in. The ODataQueryPolicy type ships in the dependency-free engine package, so the same limits apply to any non-HTTP caller of the engine.

Fix

OData unknown-property queries return 400 (not 501) — a $filter/$orderby/$select that names a property not on the entity (e.g. $select=Bogus) is bad client input and now returns 400 Bad Request. Previously the Microsoft parser’s ODataException was mapped to 501 along with $expand; only $expand (no document relationships) remains a 501.

Fix

SQLite decimal filtersdecimal comparison constants in .Where(...) / OData $filter (e.g. Price gt 100, Balance eq 49.00) now bind correctly on SQLite. Microsoft.Data.Sqlite binds a CLR decimal as TEXT, and SQLite type affinity ranks TEXT above REAL, so a numeric JSON value never matched a decimal parameter and these predicates silently returned nothing. The SQLite provider now binds decimals as REAL. Integer, double, string, and boolean filters were unaffected; other providers bind decimals natively and were never impacted.

Fix

OData $expand returns 501 (not 500)$expand on the ASP.NET Core OData host now reliably returns 501 Not Implemented as documented. Because documents have no navigation properties, the Microsoft parser raises an ODataException while binding the expand clause, which previously surfaced as a 500. The host now catches it and returns 501.

Enhancement

Aspire client — container-aware store configuration & one-flag multi-tenancy — the consuming-service AddDocumentStore(...) gains a configureServiceOptions: (sp, o) => … callback that runs with the resolved IServiceProvider, so options that depend on other registered services (e.g. resolving an interceptor or a custom tenant accessor from the container) can finally be wired through the Aspire client — the existing configureOptions (JSON contexts, type/table maps, query filters, interceptor instances) is unchanged. A new DocumentStoreSettings.MultiTenant flag registers a shared-table multi-tenant keyed store in one line: it adds the TenantId column, filters every query by the current tenant, and resolves it from a registered ITenantResolver. The same capability is now available to non-Aspire callers on the core DI package: AddDocumentStore(services, name, configure, multiTenant: true) registers a keyed shared-table tenant store (the keyed equivalent of the existing non-keyed multiTenant overload), and the lower-level AddDocumentStore(services, name, Action<IServiceProvider, DocumentStoreOptions>) is public for any DI-aware configuration of a named store.

Feature

Offline-first sync (Shiny.DocumentDb.AppDataSync) — make IDocumentStore the local cache of a backend that bidirectionally syncs over HTTP via Shiny.Data.Sync. Register AddDocumentStore(...) + AddDataSync<TDelegate>(...) + SyncDocumentStore(sync => sync.Sync<TodoItem>()), and an ordinary document type becomes two-way synced with no manual Queue/delegate plumbing: local Insert/Update/Upsert/Remove auto-enqueue to the reliable, background-capable outbox, and pulled server changes auto-apply back into the store (Create/Update → Upsert, Delete → Remove). Inbound applies run through SaveChanges(suppressInterceptors: true), so they never echo back to the server (loop guard) and fire no other interceptor. Synced types implement Shiny.Data.Sync.ISyncEntity (its string Identifier, conventionally Id.ToString()); the store and sync serializers are validated to share one JSON contract at startup. Set-based writes (ExecuteUpdate/ExecuteDelete/Clear<T>) throw SyncBulkWriteNotSupportedException on synced types (use ClearAll for a whole-store reset); batch writes enqueue each item. Client-tier providers (SQLite, LiteDB, IndexedDB).

Feature

OData v4 query endpoints (Shiny.DocumentDb.OData + Shiny.DocumentDb.AspNetCore.OData) — expose a document type as an OData entity set. $filter/$orderby/$top/$skip/$count/$select translate onto the fluent IDocumentQuery<T> and run against any provider; $expand returns 501 (no relationships). AddDocumentODataEndpoints(edm => edm.EntitySet<Customer>("customers")) + app.MapDocumentODataEntitySet<Customer>("odata/customers"). The translator engine is dependency-free and AOT/trim-clean; the ASP.NET Core host (Microsoft.AspNetCore.OData for full EDM/$metadata compliance) is JIT-only. Global AddQueryFilter predicates always apply underneath the translated $filter, so a client can’t filter past them; $count is computed pre-paging.

Feature

JSON Schema validation before write (Shiny.DocumentDb.JsonSchema) — attach a JSON Schema (draft 2020-12, powered by JsonSchema.Net) to a document type and the store validates the exact JSON about to be persisted just before the write. A failure throws DocumentSchemaValidationException (with field-level Errors) and rolls the write back — the only structural contract a schema-free store can offer. Register via DI (services.AddDocumentJsonSchema(o => o.MapJsonSchema<Customer>(schemaJson))) or inline in options (options.AddJsonSchemaValidation(...)); map schemas by JsonSchema object, JSON text, Stream, or MapJsonSchemaFromFile<T>(path) (all parsed once at registration). format (email/uuid/date-time) is asserted by default (EnableFormatAssertion = false for annotation-only). Validate what the C# type can’t — maxLength, ranges, pattern, enum, additionalProperties:false, reference-type required-ness. Schema property names match the serialized (camelCase) JSON names. Works on every provider (rides the shared interceptor path); deletes and set-based writes pass through.

services.AddDocumentJsonSchema(o => o.MapJsonSchema<Customer>("""
{ "type":"object", "required":["name","email"],
"properties": {
"name": { "type":"string", "minLength":1, "maxLength":100 },
"email": { "type":"string", "format":"email" } } }
"""));
// store.Insert(invalidCustomer) -> throws DocumentSchemaValidationException, nothing persisted
Feature

Side-effect-free writes & the serialized-JSON write context — two additions to the core write path:

  • UnitOfWork.SaveChanges(suppressInterceptors: true) commits a unit with no interceptor (per-document or bulk) firing — bounded by that transaction, so writes outside the unit still fire normally. The right tool for mirrored / authoritative data that should carry no side effects: bulk import, seeding, migration, and the inbound apply of Shiny.DocumentDb.AppDataSync. While suppressed, the multi-row batch fast path is re-enabled.
  • Inside IDocumentInterceptor.BeforeWrite, ctx.GetJson() / ctx.GetJsonDocument() expose the exact JSON about to be persisted (the store’s own options/JsonTypeInfo, cached, invalidated if an earlier interceptor replaces the document). Useful for auditing/redaction; it’s the primitive the JSON-Schema package builds on.
Feature

Batch upsert, update & removeBatchUpsert<T>, BatchUpdate<T> and BatchRemove<T> join BatchInsert<T> on IDocumentStore, collapsing many single-document round-trips into one set operation. They are all-or-nothing: on a versioned type the first version conflict throws ConcurrencyException and the whole batch rolls back. The win varies by backend (provider tier):

  • SQLite & DuckDBBatchUpsert emits a single multi-row INSERT … ON CONFLICT … DO UPDATE that deep-merges (RFC 7396) every row in one statement.
  • MongoDBBatchUpsert/BatchUpdate use one BulkWrite; BatchRemove uses one DeleteMany.
  • Cosmos — upsert/update/remove run as bounded-concurrency waves (parallel requests parallelize RU spend); same-type batches share one partition key.
  • All relationalBatchRemove issues a single DELETE … WHERE Id IN (…); BatchUpdate loops per-row inside one transaction (heterogeneous-value updates have no clean multi-row form).
  • Versioned, temporal, spatial, vector, multi-tenant, filtered, or interceptor-bound types take a per-document loop inside one transaction — correct and atomic, just not the multi-row/bulk fast path.

UnitOfWork now coalesces contiguous same-type runs of upserts, updates, and removes (not just inserts) into the matching batch method, so grouping like operations in a unit costs nothing versus calling the batch methods directly.

await store.BatchUpsert(users); // one multi-row statement on SQLite/DuckDB
await store.BatchRemove<User>(new object[] { 1, 2, 3 }); // one DELETE … IN (…)
await store.CreateUnitOfWork()
.Upsert(a).Upsert(b).Upsert(c) // → one BatchUpsert
.Remove<User>(staleId) // → one BatchRemove
.SaveChanges();
Enhancement

Cosmos bulk deletesClear<T>() and Query<T>().ExecuteDelete() on the Cosmos provider previously deleted matched rows one HTTP request at a time. They now issue the deletes in bounded-concurrency waves, cutting wall-clock on large clears dramatically.

Fix

ClearAll() deleted system-catalog tables on PostgreSQL and MySQLIDocumentMaintenance.ClearAll() lists tables via information_schema.tables, which surfaces system tables too. On PostgreSQL those system catalogs are reported as BASE TABLE, so ClearAll issued DELETE against pg_catalog/information_schema — corrupting the connection’s catalog cache (cache lookup failed for type …) and, with pooled connections, poisoning later operations on the reused connection (type "text" does not exist). On MySQL, whose information_schema is server-wide, it listed every schema’s tables (Table 'processlist' doesn't exist). The default table listing now excludes the pg_catalog/information_schema system schemas, and the MySQL provider additionally scopes to the current database (TABLE_SCHEMA = DATABASE()). SQLite, Oracle, SQL Server, and DuckDB were unaffected.

Feature

Inspect the generated query with IDocumentQuery<T>.ToQueryString() — see the SQL (or MongoDB BSON) a query would run, without executing it — for debugging, logging, and learning how an expression translates. Works for both the LINQ and string-expression Where forms (they share one pipeline). Returns a DocumentQueryString exposing Sql and a Parameters name→value map; its ToString() renders the values as a comment header above the query for copy/paste. Reflects the ToList() form including Where/OrderBy/Paginate/Select/Project. Relational providers (SQLite, SQL Server, PostgreSQL, MySQL, Oracle, DuckDB) and Cosmos return their query text + parameters; MongoDB returns its rendered BSON filter (or full find command). The in-memory providers (LiteDB, IndexedDB) — and client-side projections after Select/Project on the document providers — throw NotSupportedException.

var qs = store.Query<User>().Where(u => u.Age > 28).ToQueryString();
Console.WriteLine(qs);
// -- @typeName='User'
// -- @p0=28
// SELECT Data FROM "documents" WHERE TypeName = @typeName AND (json_extract(Data, '$.age') > @p0);
Feature

Document seeding — register IDocumentSeeders to populate initial data once at startup. Because the store is schema-free, seeding is just idempotent writes — so seeders are provider-agnostic and work against every backend. Run-once semantics are versioned via a DocumentSeedMarker document keyed on the seeder name: a seeder runs when it has never run or when its Version is greater than the recorded one — bump the version to re-run after changing seed data. Register with AddDocumentSeeder<T>() / AddDocumentSeeder(name, version, delegate) (executed at host startup via a hosted service) — pass storeName to seed a named/keyed store — or call DocumentSeedRunner.RunAsync(store, seeders) directly where there’s no generic host (e.g. MAUI).

builder.Services.AddDocumentSeeder("lookups", version: 1, async (store, ct) =>
{
await store.Upsert(new Country { Id = "CA", Name = "Canada" }, cancellationToken: ct);
});
Feature

IDocumentMaintenance.ClearAll() — whole-store reset across providers — generalises the SQLite-only ClearAllAsync to every backend. Probe with store is IDocumentMaintenance and call ClearAll() to wipe every document type (plus temporal-history, spatial, and vector sidecars) — ideal for test/dev resets. It is a whole-store wipe, not tenant- or type-scoped (use Clear<T>() for a single type); on a shared-table multi-tenant store it clears all tenants. Implemented on the relational DocumentStore (SQLite, SQL Server, PostgreSQL, MySQL, DuckDB, Oracle), MongoDB, and Cosmos; SQLite’s existing ClearAllAsync now delegates to it. Verified against the SQLite and DuckDB suites — other backends follow the same tier-by-provider rollout as temporal.

Enhancement

Interceptors can be registered from DI — in addition to AddInterceptor / AddBulkInterceptor on the options, AddDocumentStore now resolves every IDocumentInterceptor and IDocumentBulkInterceptor from the service container, so interceptors get constructor-injected dependencies (e.g. a logger or an outbox). Options-registered interceptors run first, then DI-registered ones in registration order. Resolved once from the store’s provider — register interceptors as singletons (use IServiceScopeFactory inside the hook if you need scoped services).

Fix

Enum fields in WhereIn / comparisons on PostgreSQL & DuckDB — the strict-typed providers extract JSON values with an explicit cast, but enum-typed fields fell through to a raw text extract, so WhereIn(x => x.Status, [...]) (and == on an enum field) failed with operator does not exist: text = integer. Enum fields are now cast to their underlying numeric type, matching how enums are stored. Loose-typed providers (SQLite, etc.) were unaffected.

BREAKING

RunInTransaction removed — use UnitOfWork + SaveChanges — grouping writes into one transaction is now done through a unit of work created from the store, the single public way to open a transaction. CreateUnitOfWork() is a first-class method on IDocumentStore (the old DocumentStoreExtensions.CreateUnitOfWork extension is gone), and UnitOfWork.Commit is renamed to SaveChanges (a [Obsolete] Commit alias forwards for now). Contiguous same-type inserts in a unit are coalesced into the fast batch-insert path, so grouping inserts is as fast as BatchInsert. Migrate store.RunInTransaction(tx => { await tx.Insert(a); await tx.Update(b); }) to:

var uow = store.CreateUnitOfWork();
uow.Add(a).Update(b);
await uow.SaveChanges();

A unit is a write buffer, not a tracking context — reads don’t see uncommitted buffered writes. For read-modify-write atomicity, use ETag/CAS (IfMatch) + retry. Applies to every provider.

Feature

Write interceptors — register IDocumentInterceptor (per-document) and IDocumentBulkInterceptor (set-based) to observe and mutate writes. The after-hook runs inside the transaction, after the write succeeds and before commit, with the generated id/version populated — enabling transactional outbox patterns. Per-document interceptors fire for Insert/BatchInsert (per item)/Update/Upsert/Remove; bulk interceptors fire once for ExecuteUpdate/ExecuteDelete/Clear. BeforeWrite can mutate the document or throw to abort; temporal-driven writes (Restore) are flagged Source = Temporal. Register via OnBeforeWrite<T> / OnAfterWrite<T> lambdas or AddInterceptor / AddBulkInterceptor. Supported across every provider.

opts.AddInterceptor(new AuditInterceptor());
opts.OnBeforeWrite<Order>((ctx, ct) => { /* validate / mutate ctx.Document */ return Task.CompletedTask; });
Feature

Bundled sqlite-vec for iOS, Android & desktop — Shiny.DocumentDb.Sqlite.VectorSupport — a new companion package ships the sqlite-vec native binaries (iOS static xcframework, Android .so per ABI, and macOS/Linux/Windows/Mac Catalyst loadables) and a one-call registration helper, so vector search works on every platform with no manual native setup. SqliteVec.RegisterAutoExtension() registers vec0 as a SQLite auto-extension — the only mechanism that works on iOS, where loose extensions can’t be dlopened — and SqliteVec.CreateProvider(connectionString) returns a ready provider with VectorExtensionPreloaded set. Registration is engine-aware, so it works with SQLCipher too (vec0 is registered against the e_sqlcipher engine — set VectorExtensionPreloaded = true on your SqlCipherDatabaseProvider).

// one PackageReference + one call, then map vectors as usual
opts.DatabaseProvider = SqliteVec.CreateProvider($"Data Source={dbPath}");
Fix

SQLite vector search on iOSEnableVectorExtension loads sqlite-vec via sqlite3_load_extension, which cannot work on iOS (Apple forbids dlopen of loose libraries, and the bundled e_sqlite3 disables runtime extension loading) and usually fails on Android too. Previously this surfaced as a cryptic load failure. A new SqliteDatabaseProvider.VectorExtensionPreloaded flag supports the only workable mobile path: statically link sqlite-vec and register it once via sqlite3_auto_extension(sqlite3_vec_init) at startup, then set VectorExtensionPreloaded = true to skip the runtime load entirely. SupportsVector returns true, and if both flags are set the preloaded path wins. The load-failure exception now includes platform-specific guidance on iOS/Android. Most apps should use the new Shiny.DocumentDb.Sqlite.VectorSupport package instead of wiring this by hand.

new SqliteDatabaseProvider(connectionString)
{
VectorExtensionPreloaded = true // vec0 statically linked + auto-registered
};
Feature

Scalar functions, flag-enum & phonetic queriesWhere predicates now translate a library of scalar functions across every provider: string functions (ToLower/ToUpper, Length, Trim/TrimStart/TrimEnd, Substring, Replace, IndexOf, string.IsNullOrEmpty, string concatenation), Math.* (Abs, Round, Ceiling, Floor, Sqrt, Pow, Sign), date-part access (Year/Month/Day/…), and flag-enum testspermissions.HasFlag(Permissions.Write) and the (x & flag) == flag idiom. The relational providers emit native SQL (BITAND on Oracle); MongoDB uses $expr aggregation ($toLower/$strLenCP/$substrCP/… and $bitsAllSet for flags); CosmosDB uses native NoSQL functions; LiteDB/IndexedDB evaluate in-memory. Phonetic search arrives via DocumentFunctions.Soundex(...), translated to native SOUNDEX() (SQL Server / MySQL / Oracle) or a registered connection UDF (SQLite); the same canonical implementation runs in-memory. You can register your own translations with options.MapFunctionTranslation(...). Internally the Where translator was refactored onto a shared, per-provider query IR. Soundex is also supported on PostgreSQL via the fuzzystrmatch extension, and on DuckDB/CosmosDB/MongoDB via a precomputed stored field (see Querying › Phonetic search).

var smiths = await store.Query<Account>()
.Where(a => DocumentFunctions.Soundex(a.Name) == DocumentFunctions.Soundex("Smith"))
.ToList();
var writers = await store.Query<Account>()
.Where(a => a.Permissions.HasFlag(Permissions.Write))
.ToList();
Fix

CosmosDB server-side filtering now works — CosmosDB stored each document’s body as an escaped JSON string in the data property, so c.data.field paths never resolved and every Where predicate (and query filter) silently matched zero rows server-side. Documents are now stored as nested JSON objects, so filtering, scalar functions, and flag-enum queries run on the server. A second bug that corrupted multi-parameter predicates (Substring, WhereIn) was fixed at the same time. Migration: documents written by earlier versions are still readable, but must be re-saved (e.g. Update/Upsert) to be matched by server-side filters — old rows keep the legacy string data until rewritten.

Enhancement

Scalar functions in the string Where & Project DSL — the runtime string grammar (for REST ?filter=/?fields=, saved views, admin search) now exposes the same scalar functions as the LINQ API: lower/upper, length, trim, substring, replace, indexof, abs/round/ceiling/floor/sqrt/sign, year/month/day/…, soundex, and the predicate forms isnullorempty/hasflag (alongside the existing contains/startsWith/endsWith). Functions nest and work on either side of a comparison. Projections add an as alias form for functions. Same AOT-safe translation as the compiled API.

store.Query<User>().Where("year(created) = 2026 and lower(name) = 'alice'");
store.Query<User>().Project("name, lower(email) as email, year(created) as yr");

Project(string) is now also supported on CosmosDB, MongoDB, LiteDB, and IndexedDB (previously SQL-only) — they project client-side via the same compile-free path that runs their in-memory predicates, so fields and scalar functions work everywhere.

Enhancement

Query surface is fully NativeAOT-safe — the Where translator runs on a shared expression IR (no Expression.Compile()), and the in-memory providers (LiteDB/IndexedDB) plus client-side filters now use a compile-free tree-walking interpreter instead of Expression.Compile(), removing the last RequiresDynamicCode (IL3050) holes from the query path.

Feature

Set-membership queries — WhereIn / WhereNotIn — filter to documents whose property is (or isn’t) one of an in-memory collection of values. The collection is passed as a single value and lowered to each store’s native construct (relational IN (…), Cosmos IN, MongoDB $in, LiteDB/IndexedDB in-memory) rather than expanded into the query text, so one call behaves identically across every provider. null handling is explicit via a NullHandling argument (Ignore default / Match / Raw), an empty set is well-defined (WhereIn matches nothing, WhereNotIn everything), and a string property-name overload mirrors the string OrderBy/Where helpers.

var statuses = new[] { "Open", "Pending", "Review" };
var open = await store.Query<Order>()
.WhereIn(o => o.Status, statuses)
.ToList();

The string filter’s field in (…) form now lowers through the same path, so Where("Status in ('Open','Pending')") and WhereIn(o => o.Status, …) produce identical native queries. See Querying › Set membership

Fix

Guid and enum field comparisons bind correctly across providers — predicates over a Guid property (e.g. Where(x => x.Ref == id)) or an enum property now match reliably on every relational provider. Previously a boxed Guid or enum parameter was bound in a provider-dependent shape that didn’t match the value’s JSON representation (notably Guid on SQLite and enum on DuckDB silently returned no rows). Guids are now bound as their System.Text.Json string form and enums as their underlying numeric value, so the comparison is identical to the corresponding string/number field. Applies to Where, WhereIn/WhereNotIn, and the string filter.

Feature

Optional JsonTypeInfo on the string query helpersWhere(string), OrderBy(string) / OrderByDescending(string) (incl. the direction overload), and Project(string) no longer require a JsonTypeInfo argument. When omitted, the query reuses the metadata it already resolved at creation (from Query(ctx.User) or the registered JsonSerializerContext), so the common case loses the redundant re-passing:

var results = await store.Query(ctx.User)
.Where("Age >= 30")
.OrderBy("Name", "desc")
.ToList();

Pass one explicitly to override; reflection-only queries (no resolvable context) still require it.

Feature

Runtime string filters — Where(string, JsonTypeInfo<T>) — filter with a human-friendly expression string supplied at runtime (a REST ?filter=, a saved view, an admin search box) instead of a compiled lambda. Supports and/or/not with parentheses, comparisons (==/=, !=/<>, >, >=, <, <=), field is [not] null, field in (a, b, c), and contains/startsWith/endsWith(field, 'x'). Field names match the string-OrderBy rules (case-insensitive CLR or JSON name, dotted paths) and literals are coerced to each field’s CLR type.

var open = await store.Query<User>()
.Where("Age >= 30 and Status == 'open'", ctx.User)
.ToList();

It parses to the same expression tree a compiled predicate produces and runs through the existing translator, so it never calls Compile() and resolves fields through JsonTypeInfo — fully AOT/trim-safe. See Querying › String-based Where

Enhancement

Interpolated Where($"…") — parameterized filter values — supply runtime values to a string filter as an interpolated string and each {value} hole is captured as a typed argument and bound as a parameter rather than formatted into the filter. You no longer quote string values or escape embedded quotes, and a hostile value can’t tamper with the filter (the Dapper / InterpolatedSql pattern). Holes are valid anywhere a literal would appear — comparison right-hand side, in (...) list, or contains/startsWith/endsWith argument — but never as a field name; values are coerced to the field’s CLR type and a null becomes an is null check.

var status = request.Query["status"];
var open = await store.Query<User>()
.Where($"Age >= {minAge} and Status == {status}", ctx.User)
.ToList();

An interpolated literal binds to this overload in preference to the raw Where(string) overload, so both coexist — pass a plain string (the raw ?filter= text) for the parsed form, an interpolated $"..." to capture values. Shares the same AOT-safe expression-tree path as Where(string). See Querying › Interpolated filters

Feature

Runtime field projection — Project(fields, JsonTypeInfo<T>) — project a runtime-chosen field list into IDocumentQuery<JsonObject> with no result DTO, the natural fit for REST sparse fieldsets (?fields=name,email). Rows come back as reflection-free JsonObject; pagination, Count, Any, and streaming all work on the projected query.

IReadOnlyList<JsonObject> rows = await store.Query<User>()
.Where("Age >= 30", ctx.User)
.Project("Name, Email", ctx.User)
.ToList();

Emits a json_object('name', json_extract(Data,'$.name'), …) projection; each output key is the leaf JSON name (duplicate leaves throw). Supported on the SQL providers. See Projections › Runtime field projection

Feature

Directional string sort — OrderBy(name, direction, jsonTypeInfo) — supply the sort direction as a runtime string alongside the column, e.g. for ?sort=name&dir=desc. Accepts asc/ascending/desc/descending (case-insensitive); an empty/null/whitespace direction defaults to ascending, and an unrecognized value throws. Delegates to the existing string OrderBy/OrderByDescending overloads, so it shares their AOT-safe resolution.

var results = await store.Query<User>()
.OrderBy(request.Query["sort"], request.Query["dir"], ctx.User)
.ToList();

See Querying › String-based OrderBy

Feature

Orleans grain storage (Shiny.DocumentDb.Orleans) — a Microsoft Orleans IGrainStorage (+ PubSubStore) provider implemented entirely against the backend-agnostic IDocumentStore, so one implementation runs on every DocumentDb backend. The Orleans contract maps cleanly onto the store: the document key is "{stateName}|{grainId}", the ETag is a GrainStateRecord.Version mapped via MapVersionProperty, and a ConcurrencyException surfaces as Orleans’ InconsistentStateException. Grain state is persisted as structured, nested JSON — so you can query grain state directly without activating the grains (json_extract(Data, '$.state.…') against the grain-state table — reporting/dashboards/admin over the persisted read model, which Orleans’ point-key storage contract can’t do) — and the envelope can opt into MapTemporal<GrainStateRecord> for a free audit trail of every state mutation, neither of which Orleans’ built-in providers offer.

// Relational backends — built-in path
siloBuilder.AddDocumentDbGrainStorage("Default", o =>
o.DatabaseProvider = new PostgreSqlDatabaseProvider(connectionString));

First-class companion packages Shiny.DocumentDb.Orleans.MongoDb and Shiny.DocumentDb.Orleans.CosmosDb wire the store, grain-state mapping, and version property for you (siloBuilder.AddMongoDbGrainStorage(...) / AddCosmosDbGrainStorage(...)); a StoreFactory escape hatch covers any other backend (LiteDB, IndexedDB, …). Compatibility tiers: Recommended PostgreSQL / SQL Server / MySQL / Oracle (atomic UPDATE … WHERE CAS, honored even during failover duplicate-activation windows); Supported MongoDB (atomic version-predicate filter); Limited/dev SQLite, LiteDB, IndexedDB, DuckDB; Use with care Cosmos DB (CAS is correct, but it partitions by typeName — weigh the 20 GB logical-partition limit for large single-type grain populations). Covered by integration tests on PostgreSQL + MongoDB, including a stale-write CAS conflict. There is no first-party Orleans MongoDB provider, so this fills a real gap. See Orleans Provider

Feature

Orleans system stores — reminders, clustering & grain directory — the Orleans persistence stack on Shiny.DocumentDb.Orleans now goes beyond grain storage. Each is registered with its own silo-builder extension and shares the same OrleansStoreOptions shape (relational DatabaseProvider built-in path, or a StoreFactory escape hatch for MongoDB / Cosmos / others); per-row optimistic concurrency rides on the same version-property CAS.

siloBuilder
.AddDocumentDbReminders(o => o.DatabaseProvider = new PostgreSqlDatabaseProvider(cs))
.AddDocumentDbClustering(o => o.DatabaseProvider = new PostgreSqlDatabaseProvider(cs))
.AddDocumentDbGrainDirectory("Default", o => o.DatabaseProvider = new PostgreSqlDatabaseProvider(cs));
  • Reminders (IReminderTable)AddDocumentDbReminders(...) (also calls Orleans’ AddReminders()), default table orleans_reminders. Hash-ring range reads via a fluent query on the stored GrainHash; per-row version CAS. No multi-document transaction required, so it works on any backend.
  • Cluster membership (IMembershipTable)AddDocumentDbClustering(...), default table orleans_membership. Per-silo rows and a global table-version row are updated together inside RunInTransaction, each CAS-gated. Requires multi-document transactions → relational or MongoDB replica set; Cosmos is not supported (single-partition batches only).
  • Grain directory (IGrainDirectory)AddDocumentDbGrainDirectory("Default", ...), default table orleans_graindirectory. Per-row version CAS for register/unregister races; no transaction required.

Covered by PostgreSQL integration tests (ReminderTableTests, MembershipTableTests, GrainDirectoryTests). See Orleans Provider › System stores

Feature

Source-generated (reflection-free) Orleans serialization — the Orleans provider’s internal envelope/document types (grain-state record, reminders, membership, grain directory) are now always serialized through a source-generated JsonSerializerContext, so the store handles them without reflection. Grain state T becomes source-generated too when you assign a JsonSerializerContext as o.JsonSerializerOptions.TypeInfoResolver; the new UseReflectionFallback flag (on grain-storage and all system-store options) throws a clear exception for an unregistered state type when set to false instead of falling back to reflection. Defaults (UseReflectionFallback = true, no context) preserve the prior behavior, so it’s purely opt-in. The AOT/trim analyzers are enabled on the package and the JSON-null tombstone no longer round-trips through the serializer. (The silo host itself remains a non-AOT target — Microsoft.Orleans.Runtime is reflection-heavy.) See Orleans Provider › Source-generated serialization

Fix

Atomic optimistic-concurrency CAS on MongoDB and Cosmos DB — the version-checked Update/Upsert paths previously read the stored version, compared it in memory, then wrote — a non-atomic read-then-write that could lose a concurrent writer’s update in the window between read and write (the failover edge case that bites Orleans grain storage). Both providers now perform a server-side atomic compare-and-swap: MongoDB folds the expected version into the UpdateOne filter (MatchedCount == 0ConcurrencyException), and Cosmos DB uses a native IfMatchEtag precondition on the replace (HTTP 412 → ConcurrencyException). Scoped to version-mapped types only; non-versioned writes keep last-write-wins, matching the relational providers (which were already atomic via UPDATE … WHERE version = @expected).

Feature

Temporal support (system-time history) — opt a document type into append-only versioning with options.MapTemporal<T>(o => { ... }). Every Insert, Update, Upsert, Remove, SetProperty, RemoveProperty, and BatchInsert (including writes inside RunInTransaction) records a versioned snapshot to a per-type history sidecar, so a document’s state can be read back as of any point in time.

options.MapTemporal<Order>(o =>
{
o.Retention = TimeSpan.FromDays(90); // prune expired versions older than this
o.MaxVersions = 50; // …or cap versions per document
o.CaptureActor = () => currentUser.Id; // optional "who" recorded per version
});

History query methods on the ITemporalDocumentStore capability interface (ITemporalDocumentStore : IDocumentStore) — a sibling of IObservableDocumentStore / IChangeFeedDocumentStore, not on the base IDocumentStore, since history is an optional capability rather than universal CRUD (same reasoning as the Backup/ClearAllAsync precedent). Per-document History<T>(id), AsOf<T>(id, when), Restore<T>(id, version), and GetDiffBetween<T>(id, from, to) (RFC 6902 patch between two versions — the temporal analogue of GetDiff); plus fleet-wide AsOfAll<T>(when) (point-in-time snapshot of every live document), ChangesByActor<T>(actor) (per-user audit trail), and ChangesBetween<T>(from, to) (audit log over a time window). Reads return DocumentVersion<T> (Id, Version, ValidFrom, ValidTo, Operation, Actor, Document); Remove records a null-body tombstone so AsOf returns null after a deletion.

Implemented on every provider — the relational stores (SQLite, SQLCipher, PostgreSQL, SQL Server, MySQL, Oracle, DuckDB) plus the document stores (LiteDB, MongoDB, CosmosDB, IndexedDB). Each persists versions to its own sidecar: a {table}_history table (relational, with a (Id, TypeName, Version) PK and (TypeName, ValidFrom, ValidTo) / (TypeName, Actor) secondary indexes), a {collection}_history collection (LiteDB, MongoDB), a {container}_history container partitioned by /typeName (CosmosDB), or a {store}_history object store (IndexedDB). The post-image read-back for merge/property writes and all history storage are incurred only for temporal-mapped types; non-temporal types are untouched. Retention (Retention by age, MaxVersions by count) is pruned on every write; the current version is never pruned. Clear<T> does not record per-document history. IndexedDB: bump options.Version when adding MapTemporal to an already-deployed database so the schema upgrade creates the history object stores. See Temporal Support

Feature

Telemetry & diagnostics (Shiny.DocumentDb.Diagnostics) — OpenTelemetry-native metrics and distributed tracing for any provider via a drop-in decorator. Register a store, then services.AddDocumentStoreInstrumentation(), and subscribe with .AddMeter("Shiny.DocumentDb") / .AddSource("Shiny.DocumentDb").

services.AddDocumentStore(o => o.DatabaseProvider = new SqliteDatabaseProvider("Data Source=app.db"));
services.AddDocumentStoreInstrumentation();

Built on System.Diagnostics.Metrics.Meter (created via IMeterFactory) and ActivitySource, following the OpenTelemetry database client semantic conventions: a db.client.operation.duration histogram (plus an operations counter and a returned-rows histogram), tagged db.system.name / db.operation.name / db.collection.name / outcome / error.type, and a {system}.{operation} client span per call with error status + exception capture. db.system.name is derived from the wrapped store, so it works across all 11 providers with no per-provider config. Coverage spans CRUD, the fluent-query terminals (ToList/Count/Any/ExecuteDelete/ExecuteUpdate/aggregates), the temporal ITemporalDocumentStore operations, and RunInTransaction (inner operations become child spans of the transaction span). Zero-cost when nothing is listening; never records document bodies, ids, or parameter values. NotifyOnChange/SubscribeChanges pass through untraced. See Telemetry & Diagnostics

Feature
Sortable v7 Guid Ids (UseGuidV7Ids()) — opt into time-ordered (version 7) GUID generation for Guid document Ids instead of the default random v4, via Guid.CreateVersion7(). No new dependency (BCL), and the storage format is unchanged, so it is a drop-in for existing Guid-keyed data — only newly generated Ids differ. Shorthand for MapIdType(new GuidV7IdConverter()). See CRUD › Sortable Guid Ids
Feature

Custom document Id types (MapIdType) — document Ids are no longer limited to Guid, int, long, and string. Register a converter on the store options to use any CLR type — a Ulid, or a strongly-typed wrapper such as record struct OrderId(Guid Value):

options.MapIdType(
toString: (OrderId id) => id.Value.ToString("N"),
parse: s => new OrderId(Guid.ParseExact(s, "N")),
isDefault: id => id.Value == Guid.Empty,
generate: OrderId.New); // optional auto-generation on Insert

A DocumentIdConverter<TId> base class is available for reusable/testable converters. The converter defines four things: ToStorageString, FromStorageString, IsDefault (when to auto-generate on Insert), and an optional TryGenerate. The Id is still stored as a string in every provider’s envelope (SQL Id column, Mongo _id/id, Cosmos id), so there is no schema or on-disk change. Insert, Get, Update, Remove, and Upsert all accept the strongly-typed Id. Purely additive — the built-in Guid/int/long/string types behave exactly as before with no registration. Available on every provider’s options (DocumentStoreOptions, CosmosDbDocumentStoreOptions, MongoDbDocumentStoreOptions, LiteDbDocumentStoreOptions, IndexedDbDocumentStoreOptions). Note: LINQ predicates on the Id property (Where(x => x.Id == value)) compare against the JSON document, so the type needs a matching System.Text.Json converter for the serialized form to line up. See CRUD › Custom Id types

Feature
Collection .Count / array .Length property form in predicates and projectionsWhere(o => o.Lines.Count == 0), Where(o => o.Tags.Count > 1), and projections like Select(o => new R { N = o.Lines.Count }) now translate to the same native array-length function as the .Count() method (json_array_length, jsonb_array_length, JSON_LENGTH, OPENJSON … COUNT, JSON_VALUE … .size(), ARRAY_LENGTH, and MongoDB $size) across every provider. Previously the property form was silently mistranslated to a non-existent JSON path (json_extract(Data, '$.lines.count')) that returned NULL and matched nothing — no exception, just wrong results — in both Where and Select. As part of the fix, size-like accesses that are not JSON array lengths (string.Length, dictionary .Count) now throw NotSupportedException instead of generating the same dead path — use .Count() / .Any() for collection length. A real document property literally named Count or Length still resolves normally. See Querying › Collection Count and Projections
Feature
New Shiny.DocumentDb.Oracle package — Oracle Database provider built on Oracle.ManagedDataAccess.Core (ODP.NET). Requires Oracle 23ai or later. Documents are stored as IS JSON-checked CLOB columns; Upsert runs server-side with true RFC 7396 deep merge via JSON_MERGEPATCH; SetProperty/RemoveProperty route through auto-provisioned PL/SQL helper functions (Oracle’s JSON_TRANSFORM only accepts literal paths); JSON property indexes are function-based on JSON_VALUE. A dialect adapter wraps every connection so the core’s @name placeholder conventions, name-based binding, and CLOB-sized strings all just work — raw SQL keeps the same @name syntax as every other provider. Full feature parity with MySQL: LINQ translation, projections, aggregates, batch insert, pagination, multi-tenancy, optimistic concurrency, query filters, and in-process change monitoring — verified by the full provider integration suite running against gvenzl/oracle-free via Testcontainers. Spatial and native change feeds are not supported. See Oracle
Feature
Oracle vector / ANN search — the Oracle provider now implements MapVectorProperty<T> / NearestVectors on top of Oracle 23ai’s native AI Vector Search. Embeddings are stored in a per-type sidecar table with a VECTOR(n, FLOAT32) column; VECTOR_DISTANCE(...) powers ranking (Cosine, Euclidean, DotProduct — Hamming throws) and TO_VECTOR binds the query vector. VectorIndexKind.Hnsw (ORGANIZATION INMEMORY NEIGHBOR GRAPH) and Ivf (ORGANIZATION NEIGHBOR PARTITIONS) emit a CREATE VECTOR INDEX; index creation is wrapped so databases without a configured vector_memory_size pool silently fall back to an exact sequential scan (which VECTOR_DISTANCE still serves correctly), and FETCH APPROX is used only when an index kind is requested. Where(...) predicates pre-filter via the JOIN back to the documents table. This brings the relational vector-capable provider set to PostgreSQL, SQL Server 2025, and Oracle 23ai. See Vector and Oracle
Feature
PageResult(page, pageSize, zeroBased?) extension on IDocumentQuery<T> — runs the query and returns PagedResults<T> { Records, TotalCount, Page, PageSize } in one call. TotalCount reflects the current Where filters (and global query filters) — pagination state is ignored when counting. 1-based by default to match common UI/REST conventions; pass zeroBased: true for 0-based indexing. Overrides any prior .Paginate(...) call on the query. See Pagination
Feature
String-based OrderBy / OrderByDescending extensions on IDocumentQuery<T> — sort by a property identified at runtime by name (query.OrderBy("Name", ctx.User)). Matches case-insensitively against either the CLR property name or the JSON property name (after naming policy). Supports dotted paths for nested properties ("ShippingAddress.City"). Fully AOT-safe: resolution walks JsonTypeInfo.Properties (source-generated) and synthesizes an Expression.Property(parameter, PropertyInfo) tree — no Type.GetProperty(string) reflection on T, no Expression.Compile(). Intended for dynamic UIs where the sort column is user-selected at runtime. See Ordering
Feature
Composite / multi-column JSON indexesCreateIndexAsync<T>(JsonTypeInfo<T>, params IEnumerable<Expression<Func<T, object>>>) (and matching DropIndexAsync) build a single B-tree over multiple JSON paths. SQLite, SQLCipher, PostgreSQL, MySQL, and DuckDB emit one composite index with one expression per path; SQL Server adds a PERSISTED computed column per path (cc_{indexName}_0, cc_{indexName}_1, …) and indexes them all. Existing single-path overloads keep the legacy index/column names so nothing on disk has to change. Drop discovers the index’s backing computed columns via sys.index_columns and removes them after the index, so single- and multi-column drops use the same code path. See Indexes › Composite
Feature
Vector / ANN search — register an embedding property with MapVectorProperty<T>(d => d.Embedding, dimensions: 1536, metric: VectorDistance.Cosine, indexKind: VectorIndexKind.Hnsw) and query with store.Query<T>().Where(...).NearestVectors(queryEmbedding, k: 10). Returns VectorResult<T> ({ Document, Score }) ordered nearest first. Provider-native indexes: pgvector (PostgreSQL HNSW/IVF + all four metrics including Hamming), native VECTOR(n) + VECTOR_DISTANCE (SQL Server 2025, DiskANN), embedding policy + VectorDistance() (CosmosDB DiskANN/QuantizedFlat/Flat), $vectorSearch aggregation (MongoDB Atlas HNSW), vss extension (DuckDB HNSW), sqlite-vec virtual table (SQLite flat scan with post-filter candidate multiplier). Pre-filter via Where(...) on every provider that supports it; SQLite post-filters with a configurable multiplier. Cosine score is always surfaced as distance in [0, 2] regardless of provider convention. LiteDB, IndexedDB, and MySQL throw NotSupportedException. See Vector
Feature
AutoEmbedOnInsert<T> (Shiny.DocumentDb.Extensions.AI) — plug Microsoft.Extensions.AI.IEmbeddingGenerator<string, Embedding<float>> into the new DocumentStoreOptions.OnBeforeInsert<T> pipeline so a text property is automatically embedded into a ReadOnlyMemory<float> field on Insert, BatchInsert, and Upsert. Skips when the source is null/empty or when the target already holds a non-default vector — explicit writes win over the generator. See Vector › Auto-embed
Feature
VectorIndexOptions — strongly-typed knobs for HNSW (M, EfConstruction, EfSearch) and IVF (Lists) plus a ProviderHints dictionary for the long tail (sqlite.postFilterMultiplier, atlas.indexName, atlas.numCandidates)
Feature
OnBeforeInsert<T> hook on DocumentStoreOptions — register an async handler that runs on every document before serialization on Insert/BatchInsert/Upsert. Handlers run in registration order. Used by AutoEmbedOnInsert<T> but available for any “compute derived fields” scenario
Feature
SupportsVector property on IDocumentStore and IDatabaseProvider — check vector-search availability at runtime. IDocumentStore.NearestVectors<T>(query, k, filter?) is on the interface with a default-throwing implementation, so existing providers compile without changes
Feature
Concurrent operations on server SQL providersDocumentStore now opens a connection per operation on PostgreSQL, MySQL, and SQL Server, relying on the ADO.NET driver’s built-in connection pool. A single store instance can serve concurrent callers without the operation-serializing semaphore that previous releases used. SQLite and DuckDB (embedded engines) keep the long-lived shared connection + semaphore model — opt in by overriding IDatabaseProvider.RequiresSingleConnection => true. Table init is now backed by a ConcurrentDictionary<string, Lazy<Task>> so first-touch DDL runs exactly once per table even under concurrent first calls. RunInTransaction pins one connection for the user callback so nested ops share the transaction
Fix
PostgreSQL & DuckDB multi-tenancy was silently broken — providers that wrap @data in a CAST(...) expression (Postgres’ CAST(@data AS JSONB), DuckDB’s CAST(@data AS JSON)) skipped the value-list rewrite, so INSERT ended up with 6 columns and 5 values. The substitution now anchors on (@id, @typeName, so it survives provider variations
Fix
PostgreSQL optimistic concurrency was broken — the version check used Data #>> '{Version}' = @expectedVersion, which Postgres rejects with 42883: operator does not exist: text = integer. Switched to JsonExtractTyped(..., typeof(int)) so providers emit the proper ::BIGINT (or equivalent) cast on the extracted value
Feature
New Shiny.DocumentDb.MongoDb package — MongoDB provider for Shiny.DocumentDb. Implements the full IDocumentStore API over MongoDB.Driver, storing each document as a typed BSON envelope (_id = "{TypeName}:{Id}", id, typeName, data, createdAt, updatedAt) inside a configurable collection. Includes MapTypeToCollection<T> for collection-per-type isolation, MapVersionProperty<T> for optimistic concurrency, and a sharable MongoClient for pooled clients. See MongoDB
Feature
New Shiny.DocumentDb.DuckDb package — embedded analytical store backed by DuckDB. Plugs into the standard IDatabaseProvider pipeline like SQLite/Postgres/MySQL/SQL Server, with native JSON column storage and server-side RFC 7396 Upsert via DuckDB’s json_merge_patch. The json extension is auto-loaded on every connection. See DuckDB
Feature
In-process change monitoring (IObservableDocumentStore) — consume an IAsyncEnumerable<DocumentChange<T>> of insert/update/remove/clear events with await foreach (var c in store.NotifyOnChange<User>(ct)). Channel-based fan-out — each subscriber gets its own bounded reader and unsubscribes automatically when the iterator exits or the token cancels. Changes inside RunInTransaction are buffered and emitted on commit; rollbacks discard them. Supported on DocumentStore (SQLite, SQLCipher, MySQL, SQL Server, PostgreSQL) and LiteDbDocumentStore. See Change Monitoring
Feature
Per-query change monitoring — every fluent query exposes .NotifyOnChange(ct) which filters the change stream by the query’s Where predicates: store.Query<Order>().Where(o => o.Status == "Pending").NotifyOnChange(ct). OrderBy, Paginate, and GroupBy are ignored (they affect result shape, not membership). Throws after Select(...). Property-level events (SetProperty/RemoveProperty/Remove/Clear, where Document == null) are passed through unconditionally so consumers can re-query
Feature
WhenDocumentChanged<T>(id) extension — filters the in-process change stream to events for a single document Id (plus Cleared, which affects every document of the type)
Feature
Native change feeds (IChangeFeedDocumentStore) — SubscribeChanges<T> observes the underlying database itself, including writes from other processes / connections / store instances. PostgreSQL uses LISTEN/NOTIFY with row-level triggers (true push), SQL Server uses Change Tracking with optional SqlDependency query notifications (configurable via SqlServerChangeFeedOptions), and Cosmos DB uses the native Change Feed API with an auto-provisioned lease container. Provisioning is automatic and idempotent. Throws NotSupportedException on SQLite, LiteDB, IndexedDB, MySQL, and DuckDB
Feature
DocumentChange<T> envelope — ChangeType (Inserted / Updated / Removed / Cleared), Id, and Document (populated for Inserted and full-document Updated; null for Removed / Cleared / property-level updates)
Feature
MapIdProperty<T>(...) — standalone Id-property override that no longer requires MapTypeToTable. Use it when the document Id is not literally named Id (e.g. BlogPost.Slug) but you still want the type stored in the default shared table. Expression and AOT-safe string overloads
Feature
Global query filters (AddQueryFilter<T>) — register a predicate that’s automatically AND-applied to every query of T, mirroring Entity Framework Core’s HasQueryFilter. Supports unnamed and named filters (AddQueryFilter<T>("name", ...)) with per-query opt-out via IgnoreQueryFilters() or IgnoreQueryFilters("name"). Filters apply to Query<T>() and every terminal, single-document operations (Get/Update/Remove/SetProperty/RemoveProperty/Clear), bulk operations (ExecuteUpdate/ExecuteDelete), and per-query change monitoring. Insert/BatchInsert/Upsert and raw SQL are intentionally unfiltered (matches EF Core). Captured variables are re-read on every translation, so per-request values (multi-tenancy, soft-delete, row-level scopes) work without rebuilding the store. Available on DocumentStoreOptions, LiteDbDocumentStoreOptions, CosmosDbDocumentStoreOptions, MongoDbDocumentStoreOptions, and IndexedDbDocumentStoreOptions. See Global Query Filters
Feature
MongoDB Upsert performs RFC 7396 deep merge in C# with recursive null stripping, matching CosmosDB / LiteDB / IndexedDB semantics
Feature
MongoDB RunInTransaction uses a compensating model (track inserts, delete on failure) for single-node deployments. Matches the CosmosDB provider’s behaviour. Use a replica set + custom session for true ACID multi-document transactions
Feature
DuckDB SetProperty and RemoveProperty are implemented via json_merge_patch — DuckDB has no json_set/json_remove, so the JSON path is folded into a synthetic merge-patch document server-side using list_reduce. RFC 7396 null = delete semantics are preserved on RemoveProperty
Feature
DuckDB Query<T>(string) / QueryStream<T>(string) raw SQL parity with the other SQL providers (use json_extract_string(Data, '$.path'))
Feature
Provider matrix in Provider Reference updated with DuckDB and MongoDB columns covering storage type, raw SQL support, predicate translation, deep merge, spatial, backup, and transactions
Fix
SQL Server CreateIndexAsync emitted broken DDL — the jsonPath argument was ignored; every JSON-path index registration silently produced a useless index on the TypeName column. Indexes are now backed by a persisted computed column (cc_{indexName}) over JSON_VALUE(Data, '$.path'), with a filtered CREATE INDEX on that column. DropIndex now drops both the index and its backing computed column using the required DROP INDEX … ON [table] syntax
Fix
Upsert deep-merge was shallow on PostgreSQL and SQL Server — Postgres used the jsonb || jsonb concat operator (top-level only) and SQL Server used a flat OPENJSON … FULL OUTER JOIN, both of which clobbered nested objects. Neither database has a native RFC 7396 JSON_MERGE_PATCH. Both providers now perform a row-locked read-merge-write fallback in C# (using SELECT … FOR UPDATE on PG and WITH (UPDLOCK, HOLDLOCK) on SQL Server), keeping the documented RFC 7396 deep-merge semantics
Fix
Null-stripping was shallow across all providers — when an Upsert patch contained a nested object whose other properties were null (e.g. new Doc { Address = new Address { City = "X" } }), the unfilled street/state nulls reached the merge step and were interpreted as RFC 7396 deletions, silently wiping the stored values. Null-stripping is now recursive, so partial nested patches preserve unspecified fields on SQLite, MySQL, LiteDB, IndexedDB, Cosmos DB, PostgreSQL, and SQL Server
Fix
MySQL — DropIndexAsync emitted DROP INDEX {name};, which is invalid in MySQL. Now emits proper drop index on table
Feature
Unit of Work — new CreateUnitOfWork() extension method on IDocumentStore returns a UnitOfWork that buffers Add/Update/Remove operations and applies them atomically inside a single transaction on Commit(). The queue is auto-cleared on successful commit; on failure the transaction is rolled back and the queue is preserved for inspection or retry. Works across every provider via RunInTransaction. See Unit of Work
Feature
Shiny.DocumentDb.IndexedDb is now 100% AOT/reflection-free. The JS interop layer was rewritten to use [JSImport] from System.Runtime.InteropServices.JavaScript instead of IJSRuntime.InvokeAsync. The library no longer requires JsonSerializerIsReflectionEnabledByDefault=true to function — apps targeting AOT or trim-safe deployments can use the IndexedDB provider without re-enabling reflection
Feature
Internal source-generated JsonSerializerContext (camelCase) for DocumentRecord wire-format serialization — the library no longer depends on the consuming app’s JsonSerializerOptions for envelope types. Existing IndexedDB databases remain readable; the wire format is unchanged
Feature
Module loading switched from IJSRuntime.InvokeAsync<IJSObjectReference>("import", ...) to JSHost.ImportAsync(...). No app-side changes required
Fix
IndexedDbDocumentStore no longer throws JsonSerializerIsReflectionDisabled when the host app has reflection disabled. In 5.1 and earlier, the library’s reliance on Blazor’s IJSRuntime Object[] arg envelope forced apps targeting AOT to either drop the IndexedDB provider or globally re-enable reflection
Fix
Query operations (Count, Any, ToList, ToAsyncEnumerable, ExecuteDelete, ExecuteUpdate, Max, Min, Sum, Average, and projected Select queries) now initialize the type-specific table before executing. Previously, the query path always initialized the default TableName, so calling a query method against a type registered with MapTypeToTable<T>() before any insert had created its table raised no such table: <Name>
Fix
SQLite — table identifiers are now properly quoted in all generated DDL/DML. Mapping a type whose name collides with a SQL reserved word (e.g. Order, Group, User) no longer produces syntax error at table creation or insert time
BREAKING
Backup removed from IDocumentStore interface — now available only on concrete types: SqliteDocumentStore.Backup(), SqlCipherDocumentStore.Backup(), and LiteDbDocumentStore.Backup()
BREAKING
Provider-specific DI extension methods removed (AddSqliteDocumentStore, AddSqlCipherDocumentStore, AddSqlServerDocumentStore, AddMySqlDocumentStore, AddPostgreSqlDocumentStore, AddLiteDbDocumentStore, AddCosmosDbDocumentStore, AddIndexedDbDocumentStore). Use AddDocumentStore from Shiny.DocumentDb.Extensions.DependencyInjection instead
Feature
Named/keyed document store support — AddDocumentStore("name", opts => ...) registers stores as .NET keyed singletons. Inject with [FromKeyedServices("name")] or resolve dynamically via IDocumentStoreProvider.GetStore("name")
Feature
Multi-tenancy support — two isolation strategies via Shiny.DocumentDb.Extensions.DependencyInjection: shared-table (single database with automatic TenantId column filtering) and tenant-per-database (separate database per tenant via lazy factory)
Feature
ITenantResolver interface — implement to provide the current tenant ID. Used by both multi-tenancy strategies to auto-resolve tenant context per request
Feature
AddDocumentStore(configure, multiTenant: true) — shared-table multi-tenancy registration. Adds a dedicated TenantId column and index to the schema; all queries are automatically filtered by the current tenant
Feature
AddMultiTenantDocumentStore(Func<string, DocumentStoreOptions>) — tenant-per-database registration. Each tenant gets a lazily-created separate database. IDocumentStore is registered as scoped and resolves to the correct tenant automatically
Feature
TenantIdAccessor on DocumentStoreOptions — core pipeline hook for shared-table multi-tenancy. When set, all queries include a TenantId filter and all inserts include the tenant value. A dedicated column and index are created automatically
Feature
New Shiny.DocumentDb.IndexedDb package — IndexedDB provider for Blazor WebAssembly with IndexedDbDocumentStore. Zero native dependencies, persists to the browser’s IndexedDB via JS interop
Feature
SQLite WASM compatibility — SqliteDatabaseProvider now skips WAL pragma on OperatingSystem.IsBrowser(), spatial R*Tree is disabled in WASM, and Backup() is marked [UnsupportedOSPlatform("browser")]
Feature
New Shiny.DocumentDb.LiteDb package — LiteDB provider with LiteDbDocumentStore
Feature
New Shiny.DocumentDb.CosmosDb package — Azure Cosmos DB provider with CosmosDbDocumentStore
Feature
Spatial/geo query support — WithinRadius, WithinBoundingBox, and NearestNeighbors methods on IDocumentStore with default NotSupportedException for unsupported providers
Feature
GeoPoint readonly record struct — represents a WGS84 coordinate, serializes as GeoJSON {"type":"Point","coordinates":[lng,lat]}
Feature
GeoBoundingBox readonly record struct for area-based spatial queries
Feature
SpatialResult<T> wrapper — returns documents with computed DistanceMeters from the query center point
Feature
MapSpatialProperty<T> on DocumentStoreOptions — register which GeoPoint property to use for spatial indexing per document type
Feature
SQLite spatial support via R*Tree virtual tables — automatic sidecar table creation and CRUD sync for spatial-indexed documents
Feature
CosmosDB spatial support via native ST_DISTANCE and ST_WITHIN GeoJSON queries with automatic spatial index policy
Feature
SupportsSpatial property on IDocumentStore — check if the current provider supports spatial queries at runtime
Feature
SqliteDocumentStore.ClearAllAsync() — deletes all documents across all tables in the SQLite database, including spatial sidecar tables
Feature
Optimistic concurrency via document-level version properties — MapVersionProperty<T>(x => x.RowVersion) on all provider options classes. Version is set to 1 on insert, checked and incremented on update/upsert. Throws ConcurrencyException on mismatch. Stored inside the JSON blob — zero schema changes required
Feature
AOT-safe MapVersionProperty<T> overload — MapVersionProperty<T>(string propertyName, Func<T, int> getter, Action<T, int> setter) for trimming-safe deployments
Feature
ConcurrencyException — new exception type with TypeName, DocumentId, ExpectedVersion, and ActualVersion properties for diagnosing version conflicts
Feature
New Shiny.DocumentDb.Extensions.AI package — exposes IDocumentStore operations as Microsoft.Extensions.AI tool functions for LLM agents
Feature
AddDocumentStoreAITools DI extension — opt-in registration of document types with per-type capability flags (ReadOnly, All, or individual Get/Query/Count/Aggregate/Insert/Update/Delete)
Feature
Seven AI tool functions generated per type (when using All): get_by_id, query, count, aggregate, insert, update, delete
Feature
Structured JSON filter expressions with and/or/not combinators and leaf comparisons (eq, ne, gt, gte, lt, lte, contains, startsWith, in) — translated to LINQ expressions at runtime
Feature
Per-type builder API: Description(), Property() description overrides, AllowProperties() / IgnoreProperties() for field visibility control, MaxPageSize() to cap query results
Feature
AOT-safe — all tool schemas and serialization use JsonTypeInfo<T> from source-generated JSON contexts
Feature
Aggregate tool supports count, sum, min, max, avg functions with optional structured filters
Feature
DocumentStoreAITools wrapper class — resolve from DI and pass .Tools to IChatClient / ChatOptions.Tools
Feature
GitHub Copilot sample app demonstrating interactive document management via LLM chat
Feature
New Shiny.DocumentDb.Sqlite.SqlCipher package — encrypted SQLite via SQLCipher with a separate native bundle, no changes to the existing Shiny.DocumentDb.Sqlite package
Feature
SqlCipherDatabaseProvider(filePath, password) — explicit file path and password parameters so users know exactly what is required
Feature
SqlCipherDocumentStore convenience wrapper and AddSqlCipherDocumentStore DI extension for quick setup
Feature
RekeyAsync extension method on IDocumentStore — change the encryption key of an existing SQLCipher database via PRAGMA rekey with SQL injection protection
Feature
Backup support for SQLCipher — automatically propagates the encryption password to the backup database
Feature
DocumentStore.DatabaseProvider public property — exposes the underlying IDatabaseProvider for extension methods
BREAKING
Removed SystemTextJsonPatch dependency — replaced with built-in AOT-compatible JsonPatchDocument<T> and JsonPatchOperation types that use JSON DOM manipulation instead of reflection
BREAKING
JsonPatchDocument<T>.ApplyTo() now returns a new T instead of mutating the target in place — var patched = patch.ApplyTo(original)
Feature
New JsonPatchOperation immutable type with static factory methods: Add, Replace, Remove, Copy, Move, Test
Feature
New JsonPatchDocument<T> with AOT-safe overload accepting JsonTypeInfo<T>patch.ApplyTo(target, MyJsonContext.Default.MyType)
Feature
BatchInsert<T> now uses multi-row INSERT statements chunked into batches of 500 rows, significantly reducing database round-trips — especially impactful for PostgreSQL
BREAKING
Package renamed from Shiny.SqliteDocumentDb to Shiny.DocumentDb with separate provider packages: Shiny.DocumentDb.Sqlite, Shiny.DocumentDb.SqlServer, Shiny.DocumentDb.MySql, Shiny.DocumentDb.PostgreSql
BREAKING
ConnectionString removed from DocumentStoreOptions — replaced by required IDatabaseProvider DatabaseProvider. The connection string is now passed to each provider’s constructor
BREAKING
DI extensions bundled into each provider package — no separate Shiny.SqliteDocumentDb.Extensions.DependencyInjection package
BREAKING
SqliteDocumentStore moved to Shiny.DocumentDb.Sqlite namespace. Base class is now DocumentStore in Shiny.DocumentDb
Feature
SQL Server provider via Shiny.DocumentDb.SqlServer with AddSqlServerDocumentStore DI extension
Feature
MySQL provider via Shiny.DocumentDb.MySql with AddMySqlDocumentStore DI extension
Feature
PostgreSQL provider via Shiny.DocumentDb.PostgreSql with AddPostgreSqlDocumentStore DI extension
Feature
Provider-agnostic IDatabaseProvider interface — swap database backends without changing application code
BREAKING
DI extensions moved to separate Shiny.SqliteDocumentDb.Extensions.DependencyInjection package — the core library no longer depends on Microsoft.Extensions.DependencyInjection.Abstractions
Feature
Convenience constructor — new SqliteDocumentStore("Data Source=mydata.db") for quick setup without options
Feature
Configurable default table name via DocumentStoreOptions.TableName (defaults to "documents")
Feature
Table-per-type mapping — MapTypeToTable<T>() gives a document type its own dedicated SQLite table with lazy creation on first use
Feature
Auto-derived or explicit table names — MapTypeToTable<T>() derives from the type name, MapTypeToTable<T>(string) uses an explicit name
Feature
Duplicate table name protection — mapping two types to the same custom table throws InvalidOperationException
Feature
Custom Id property mapping — MapTypeToTable<T>("table", x => x.MyProperty) uses an alternate property as the document Id instead of the default Id
Feature
Fluent options API — all MapTypeToTable overloads return DocumentStoreOptions for chaining
Feature
Document diffing via GetDiff<T>(id, modified) — compares a modified object against the stored document and returns an RFC 6902 JsonPatchDocument<T> with deep nested-object diffing powered by SystemTextJsonPatch
Feature
All new features are fully AOT-safe — type names and Id property names are resolved at registration time, not at runtime
Feature
Batch insert via BatchInsert<T>(IEnumerable<T>) — inserts a collection in a single transaction with prepared command reuse, auto-generates IDs, and rolls back atomically on failure
Feature
Schema-free JSON document storage on top of SQLite
Feature
Mandatory typed Id property on document types (Guid, int, long, or string) — stored in both the SQLite column and the JSON blob so query results always include it
Feature
Auto-generation of Ids on Insert: GuidGuid.NewGuid(), int/longMAX(CAST(Id AS INTEGER)) + 1 per TypeName. String Ids must be set explicitly — Insert throws for default string Ids
Feature
LINQ expression queries translated to json_extract SQL with support for equality, comparisons, logical operators, null checks, string methods, nested properties, and collection queries
Feature
Fluent query builder (IDocumentQuery) — chain .Where(), .OrderBy(), .OrderByDescending(), .GroupBy(), .Paginate(), .Select() and terminate with .ToList(), .ToAsyncEnumerable(), .Count(), .Any(), .ExecuteDelete(), .ExecuteUpdate(), .Max(), .Min(), .Sum(), .Average()
Feature
Pagination via .Paginate(offset, take) — translates to SQL LIMIT/OFFSET
Feature
Expression-based ordering — .OrderBy(u => u.Age) and .OrderByDescending(u => u.Name) on the fluent query builder
Feature
SQL-level projections via .Select() using json_object for extracting specific fields without full deserialization
Feature
IAsyncEnumerable streaming via .ToAsyncEnumerable() — yield results one-at-a-time without buffering
Feature
Expression-based JSON indexes for up to 30x faster queries on indexed properties
Feature
Full AOT and trimming support — all JsonTypeInfo parameters are optional and auto-resolve from configured JsonSerializerContext
Feature
Scalar aggregates: .Max(), .Min(), .Sum(), .Average() as terminal methods on the query builder
Feature
Aggregate projections with automatic GROUP BY via Sql.Count(), Sql.Max(), Sql.Min(), Sql.Sum(), Sql.Avg() marker methods
Feature
Collection-level aggregates in projections: Sum, Min, Max, Average on child collections (e.g. o.Lines.Sum(l => l.Quantity))
Feature
Explicit Insert / Update / Upsert API — Insert throws on duplicate Ids, Update throws if not found, Upsert deep-merges via json_patch
Feature
SetProperty — update a single scalar JSON field via json_set without deserializing the document. Supports nested paths
Feature
RemoveProperty — strip a field from the stored JSON via json_remove. Works on any property type
Feature
Typed Id lookups — Get, Remove, SetProperty, and RemoveProperty accept the Id as object (Guid, int, long, or string). Unsupported types throw ArgumentException
Feature
Bulk delete via query builder — .Where(predicate).ExecuteDelete() returns count of deleted documents
Feature
Bulk update via query builder — .Where(predicate).ExecuteUpdate(property, value) updates a property on matching documents via json_set() and returns count updated
Feature
Transactions with automatic commit/rollback via RunInTransaction
Feature
Hot backup via store.Backup(path) — copies the database to a file using the SQLite Online Backup API while the store remains usable
Feature
Dependency injection registration via AddSqliteDocumentStore
Feature
Configurable type name resolution (ShortName or FullName)
Feature
UseReflectionFallback option for strict AOT enforcement
Feature
SQL logging callback via DocumentStoreOptions.Logging
Feature
Raw SQL query and streaming support via store.Query(whereClause) and store.QueryStream(whereClause)