Shiny.DocumentDb — Doing All The Things!
There’s a moment every schema-free database eventually hands you, and it always arrives at the worst
possible time. Something looks wrong in production. You open a SQL client, type SELECT Data FROM documents, and get back a wall of minified JSON in a single cell. Somewhere in there is the field that’s
null when it shouldn’t be. Good luck.
That’s the tax on “no CREATE TABLE, no migrations.” You give up the schema, and with it you give up
every tool that assumed one. Every JSON function is spelled differently on every engine, so the query you
figured out on PostgreSQL is useless on SQLite. I’ve written that json_extract incantation from memory
more times than I care to admit, and I wrote the library.
So I built the thing I kept wishing existed: a phpMyAdmin for document stores. That’s the headline of v12 — but it turned out to be three releases in three days, and the rest of it is a genuinely wide spread. Hence the title.
ShinyDocDbMyAdmin
docker run -p 8085:8080 -v shiny-docdb-myadmin:/data ghcr.io/shinyorg/shiny-docdb-myadmin
That’s the install. It’s a container image and nothing else — no dotnet tool. I tried the tool route
first and it came out around 120 MB, almost entirely native provider binaries for every RID it might
conceivably run on, to deliver exactly what one image delivers for the platform you’re actually on. Not
worth it.
No user management, no server administration, no query planner, no “optimise your indexes” nag screen. Just the documents.
The grid figures out your columns

This is the bit I’m smug about. It samples the documents in a type and infers the columns, so
customer.tier is a first-class column you can filter and sort on, not something buried three levels down
in a cell. Filter on any envelope column or JSON path, or just quick-search the string fields.
Numeric sorts compare numerically, which sounds like table stakes and absolutely is not — on most engines
the plain JSON extract hands back text, so a naive sort cheerfully puts "100" before "9". Ask me how
I know.
Click any row and the whole body expands, syntax-highlighted, with objects and arrays collapsible. Those
are native <details> elements, so it still works with scripting off.
It tells you what your documents actually contain

Path, type, example value, and presence. That last column is the one I reach for constantly. The store is schema-free, so this isn’t a contract — it’s a description of what the sampled documents contain. A field sitting at 87% means it’s missing from some documents, and about half the time that’s the bug you came here to find.
Every path also gets a one-click index button, and the index it creates is named exactly the way
DocumentDb names its own — so it’s the same index CreateIndexAsync would have made, not a shadow copy
that confuses everyone later.
History, with a real diff

If a type is mapped with MapTemporal<T>, a History tab appears. Pick a document, pick any two versions,
and you get a field-by-field diff by dotted path — or the two bodies side by side if you’d rather eyeball
it. Any prior version restores behind a two-click confirm.
One design decision here I want to call out, because it was the hardest part of the whole tool. When
this UI edits a temporal-mapped type, it records a version too, stamped with the actor
shiny-docdb-myadmin. That’s not politeness — the admin layer writes SQL directly, because it sits below
IDocumentStore and has no CLR type to bind to, so none of the library’s temporal tracking runs on its
own. Without deliberately re-implementing it, an edit made here would quietly change the row while the
history sidecar went on insisting the old body was still current.
A silently wrong audit trail is worse than no History tab at all. So it uses the provider’s own history SQL and does it properly.
And it draws your geometry

Any type storing GeoJSON gets a map — server-rendered SVG, zoom-to-feature, fit-all, with vertices, length, area, centroid and OGC validity per document.
The trick is that it reads the document body, not the {table}_spatial sidecar. The sidecar only ever
holds bounding boxes for index pruning and its shape differs per provider; the GeoJSON in Data is the
actual value and it’s identical everywhere. Which means the map works on every provider — including the
ones with no spatial support whatsoever. Parsing goes through the library’s own GeometryJsonConverter,
so the tool inherits the real OGC model instead of reimplementing half of it badly.
Plus a SQL console, because sometimes you just need SQL

Whatever you want to type, in the target database’s own dialect, with @name parameters bound from a JSON
box so the types stay honest. There’s no statement allow-list — so give the tool a database account with
the privileges you actually want it to have, and mark the connection read-only when you’re pointing it at
something you didn’t create five minutes ago.
There’s also an Edit tab (JSON editor with format and validation, insert, duplicate, bulk delete), a Blobs tab that lists payloads without ever selecting the blob column, Import / Export for JSON, NDJSON, CSV and round-trippable envelope JSON, and a Create flow so a database whose store has never run isn’t a dead end.
It covers every relational backend: SQLite, SQLCipher, DuckDB, PostgreSQL, SQL Server, MySQL, MariaDB,
Oracle 23ai+ and CockroachDB. The document stores are deliberately out of scope — the whole tool works
against the shared Id / TypeName / Data / CreatedAt / UpdatedAt envelope over ADO.NET, and only the
relational providers expose that.
You shouldn’t have to remember a docker run line
So in Aspire you don’t:
var store = builder.AddPostgresDocumentStore("orders");
builder.AddDocumentDbAdmin(port: 8085)
.WithReference(store)
.WithDataVolume()
.WaitFor(store);
It comes up with the rest of your app and every store you reference is already connected. WithReference
is the same call a consuming service makes — the tool reads the ConnectionStrings:{name} +
Shiny:DocumentDb:{name}:Provider pair the hosting integration already emits, so a Redis reference in the
same AppHost gets ignored rather than turning into a junk connection you have to delete every run.
Host-provided connections show up under a from host badge and can’t be edited from the UI. They’re
declared in the AppHost — that’s where they change. Chain WithHostPath to reach a file-backed store from
inside the container, WithSecretKey to pin the key that encrypts saved profiles, and WithReadOnly()
when you’d rather not find out.
Documents with no type at all
Here’s the other thing v12 does, and it’s the feature I’d been avoiding for about four versions.
DocumentDb has always been schema-free, but not type-free — you brought a CLR class and it stored the JSON. Which is great, right up until you’re building generic HTTP intake, or a message-bus landing zone, or an ETL staging area, or the “extra fields” bag a multi-tenant app inevitably grows. In all of those, the shape is decided by whoever sent it. There is no class to write.
var orders = store.Collection("orders");
var id = await orders.Insert(jsonObject); // returns the stored id
var doc = await orders.Get(id); // JsonObject?
var rows = await orders.Query()
.Where("customer.name == 'bob' and total:number > 100")
.OrderBy("total:number desc")
.Paginate(0, 50)
.ToList(); // IReadOnlyList<JsonObject>
No CLR type. No registered mapping. No migration. The collection name becomes the row’s TypeName, so a
schema-free collection shares a table with your typed documents without either one seeing the other, and
needs zero schema change to start using.
Querying is the string grammar only — there’s no T to write a lambda against — and it lowers to exactly
the same IR and the same SQL as Query<T>().Where("…"). The interesting problem is that nothing tells the
engine what a field is, so the type gets inferred as you build the expression: from an explicit
path:type hint, else from the other operand (total > 100 is obviously numeric), else from the function
(lower(name) is a string), else string.
That last fallback is why you’ll see total:number in my examples. Wherever nothing pins the type — an
OrderBy, a min/max, a numeric projection — you need the hint, because on every provider whose plain
JSON extract returns text, OrderBy("total") sorts lexicographically and your top spender is whoever
spent $99.
The same object serves the type-keyed lane too — store.Collection(typeof(Order)) — where paths
resolve through the type’s metadata, the body still rides the full write pipeline (tenancy, temporal,
versioning, spatial and vector sidecars, interceptors, change notifications), and you get the complete
function set including hasflag, the geo predicates and the Lucene functions. That lane picked up a
fluent query builder and Remove/BatchRemove/Clear, which it somehow never had.
Breaking, and unapologetically so: the eight Type + JsonNode members on IDocumentStore are gone,
replaced by this one surface. store.Insert(type, node) becomes store.Collection(type).Insert(obj). No
[Obsolete] shims — this is an OSS library and I’d rather ship a clean break with a release note than
carry forwarding stubs forever. The mapping table is in the release notes.
Relational providers only. Everything else throws NotSupportedException, and there’s a test per provider
pinning that so it can’t quietly drift.
Interceptors that can say “I’ve got this”
BeforeWrite could previously do two things: mutate the document, or throw. And throwing aborts the write
and the surrounding unit of work, which means an interceptor could never change the shape of an
operation. Turn a delete into an archive? Not possible. That bugged me for a while.
public class ArchiveOnDelete : IDocumentInterceptor
{
public async Task BeforeWrite(DocumentWriteContext ctx, CancellationToken ct)
{
if (ctx.Operation == DocumentOperation.Remove && ctx.Document is Invoice inv)
{
inv.Archived = true;
await ctx.Store.Update(inv, ct); // transaction-bound
ctx.Cancel(); // "I performed this write myself"
}
}
}
Cancel() means the store issues no write for that operation: no AfterWrite, no change
notification, no temporal history entry, and later interceptors get skipped. The caller still gets a
normal-looking result — Cancel(false) makes Remove return false, and the bulk form’s Cancel(n)
makes ExecuteDelete/ExecuteUpdate/Clear return n. Your replacement write goes through
ctx.Store/ctx.Session, which are transaction-bound, so it commits with whatever unit the original
write belonged to.
Every provider. Which took some doing — see the next section.
…which is how soft delete got built
Soft delete is the single most-requested feature I’ve had, and I kept not building it because every design I sketched ended up wired into the guts of ten providers.
It isn’t. It’s a named query filter plus a cancelling interceptor, composed from the two public building blocks above and shipped as an extension method:
services.AddDocumentStore(o => o
.AddSoftDelete<Customer>(x => x.IsDeleted)
);
await store.Remove(customer); // sets the flag
await store.Query<Customer>().ToList(); // flagged docs are invisible
await store.Query<Customer>().IncludeDeleted().ToList(); // read past it
await store.Query<Customer>().OnlyDeleted().ToList(); // just the flagged ones
Remove, ExecuteDelete() and Clear<T>() all set the flag instead of deleting. The flag can be a
bool, or a nullable DateTime/DateTimeOffset stamped from a DI-registered TimeProvider. You also
get SoftDelete<T>(id), Restore<T>(predicate), PurgeDeleted<T>(predicate?), HardDelete<T>(id) and
SuppressInterceptors() for a raw write.
The part I like: because a set-based delete gets re-issued as an update over the same query, the spatial/vector/blob sidecar rows just stay attached to the document instead of being orphaned. That fell out of the design rather than needing special-casing, which is usually the sign you picked the right one.
And since it’s built from public parts, you can write your own variant exactly the same way.
The boring change that was actually the biggest one
Here’s the part with no screenshots and no API to show you, and it’s the change I’d defend hardest.
The nine non-relational query implementations were 70–88% identical. Same builder state, same client-side
terminals, same interceptor plumbing, retyped nine times. Every document provider’s
Insert/Update/Upsert/Remove opened with the same preamble and closed with the same tail. Nine
copies. When I shipped ctx.Cancel() in v11.4 it needed 45 hand-edited guards — that was the moment
the bill came due.
v12 collapses it:
DocumentQueryBase<T>— a provider now supplies aClone, anExecuteAsync(QueryPlan<T>), and two set-based write primitives. Push-down stays explicit rather than magic:ExecuteAsynchands back aQueryExecution<T>declaring how much of the plan the engine actually satisfied (Completefor MongoDB/Cosmos,Candidatesfor the key-value stores,Partialin between), and the base applies only the remainder client-side.IDocumentStoreOptions— a three-method interface every options class implements, so a cross-cutting feature gets written once.AddSoftDelete<T>collapsed from ten files to one, andMapJsonSchemawent from relational-only to every provider for free.- A shared single-document write pipeline. Persistence, conflict handling and id generation stay per
provider, because Redis
SET NX, Cosmos ETag, DynamoDB conditional writes and Azure Table 409s genuinely are different problems.
~4,300 lines deleted. And DocumentQueryBase<T> is public, so an out-of-repo provider now implements
four members instead of ~450 lines.
The safety net is a pair of cross-provider conformance suites that assert the IDocumentQuery<T>
contract, soft delete, and interceptor cancellation identically on all 17 providers. On their very first
run they found four real bugs that had already shipped:
- Bools in a predicate were broken on MySQL, MariaDB and SQL Server. A
boolJSON value came out as the text'true'/'false'and got compared against1/0, soWhere(x => x.IsActive)— or any bool query filter, soft delete’s included — died withTruncated incorrect DOUBLE value: 'false'. SQL Server had its own flavour, becauseBITis a value, not a condition. - Cosmos
ExecuteUpdate404’d, because the narrowedSELECT c.id, c.datadroppedtypeName— the partition key — before writing back. - Cosmos
SetProperty/RemovePropertyignored global query filters, so a soft-deleted document could still be updated by id. SetPropertywith a date orGuidwrote malformed JSON, an unquoted invariantToString()thatjson_setrejected outright.
Four defects, sitting in shipped code, invisible because every provider was tested against its own expectations. That’s the entire argument for the refactor, and I’d make it again.
One breaking change comes with it, and it’s sneaky: query builders are immutable everywhere now. The
relational store used to mutate in place and return the same instance; every document provider has always
returned a copy. Both return a copy now, which is what IQueryable/EF Core callers expect — but it means
a builder call used as a statement silently loses its clause, and the compiler cannot flag it for you.
q.Where(x => !x.IsDeleted); // ← discarded
q = q.Where(x => !x.IsDeleted); // ← correct on every provider
Go grep for it. It’s a five-minute sweep.
Trim and AOT, actually verified
v12.1 was a dedicated pass, and it fixed something slightly embarrassing: no package declared
IsAotCompatible. Not one. Which meant a consumer’s trimmer kept every DocumentDb assembly whole instead
of trimming into it — you were paying for code you never called.
The whole src/ tree now builds with zero IL2026/IL2075/IL2090/IL3050 warnings and packages declare
IsAotCompatible. The handful whose dependencies rule AOT out declare IsAotCompatible=false honestly
rather than staying quiet about it.
The mapping APIs also picked up [DynamicallyAccessedMembers(PublicProperties)] on T, so the properties
they resolve by name are genuinely preserved. They previously carried IL2075 suppressions that didn’t
match the warning the analyzer actually emits (IL2090), which is a wonderful little way to have
suppressions that suppress nothing while preserving nothing.
Best part: it’s checked rather than assumed. samples/Sample.Aot publishes with PublishAot=true,
exercises every mapping kind and query surface once, treats any trim warning as a build error, and CI
publishes and runs it. The Roslyn analyzers can’t see warnings coming out of dependencies or code only
reachable through a call graph. A real ILC run can.
Everything else it does
If you’ve landed here without ever having used DocumentDb, the pitch is one sentence: it turns any
database into a schema-free JSON document store with LINQ querying — no CREATE TABLE, no migrations, one
API across 19 backends.
Providers — SQLite, SQLCipher, LiteDB, IndexedDB (Blazor WASM), DuckDB, PostgreSQL, CockroachDB, SQL Server, MySQL, MariaDB, Oracle, Cosmos DB, MongoDB, Amazon DocumentDB, Azure Table Storage, DynamoDB, Redis, RavenDB, Google Firestore.
| Area | What you get |
|---|---|
| CRUD | Insert/Update/Upsert/Remove over whole object graphs; RFC 7396 merge-patch vs replace as a flag; SetProperty/RemoveProperty for surgical field updates; GetDiff returning an RFC 6902 patch; batch writes as one set operation |
| Querying | Fluent LINQ over nested properties, Any(), string methods, captured variables; IAsyncEnumerable streaming; ordering, pagination and keyset cursors; a string-expression grammar with full parity; Lucene-syntax queries |
| Projections & aggregates | SQL-level .Select() into DTOs; Max/Min/Sum/Average terminals; GroupBy/Having pushed down to real SQL GROUP BY |
| Indexes | Expression-based JSON indexes (up to 30× faster), composite multi-column indexes, computed properties as alias or materialized generated columns |
| Spatial | Full OGC Geometry model, topological predicates (GeoIntersects, GeoWithin, …), real 2-D indexes on every SQL provider plus Cosmos ST_* and Mongo 2dsphere |
| Vector | MapVectorProperty + NearestVectors(query, k) on pgvector, SQL Server 2025 VECTOR, Oracle 23ai, Cosmos DiskANN, Mongo Atlas $vectorSearch, DuckDB vss, sqlite-vec; AutoEmbedOnInsert via IEmbeddingGenerator |
| Full-text | MapFullTextProperty + relevance-ranked FullTextSearch<T> on FTS5, tsvector+GIN, MySQL FULLTEXT, Oracle Text, SQL Server FTS, DuckDB fts, Cosmos, Mongo $text |
| Temporal | MapTemporal<T> append-only versioning with History/AsOf/Restore/GetDiffBetween/ChangesByActor, on every provider |
| Blobs | DocumentBlob sidecar storage loaded on demand, so ordinary reads and the change feed never drag the bytes along |
| Interceptors & filters | Per-document and set-based write hooks that run inside the transaction; EF-style global query filters with named IgnoreQueryFilters |
| Change monitoring | IAsyncEnumerable<DocumentChange<T>>, per-query .NotifyOnChange(), plus native change feeds on PostgreSQL LISTEN/NOTIFY, SQL Server Change Tracking and Cosmos Change Feed |
| Typed context | EF-Core-style DocumentContext + DocumentSet<T>, source-generated from [Document] attributes |
| Backup | Streaming export/import/restore with native bulk-copy fast paths (COPY, SqlBulkCopy, DuckDB appender); hot file backup on SQLite/SQLCipher/LiteDB |
| Multi-tenancy | Shared-table TenantId filtering or tenant-per-database via ITenantResolver — consumer code unchanged |
| Concurrency | MapVersionProperty optimistic concurrency, ETag/conditional CAS, OpenSession() unit of work with explicit transactions and lock modes |
| Orleans | Grain storage, reminders, cluster membership and grain directory — with grain state queryable without activating grains |
| AI tools | Microsoft.Extensions.AI tool functions per document type, capability-gated, with non-removable per-type filter scopes |
| OData | $filter/$orderby/$top/$skip/$count/$select onto the fluent query, with per-entity-set governance |
| Data sync | Offline-first bidirectional sync to an HTTP backend via Shiny.Data.Sync |
| Diagnostics | Always-on OpenTelemetry metrics and ActivitySource spans, zero-cost when nobody’s listening |
| Validation | JSON Schema (draft 2020-12) enforced immediately before the write |
| Geo reference data | Embedded US/CA states, provinces and cities that seed straight into any store |
| Aspire | The provider becomes a deployment decision — keyed stores with health checks + OTel, and the admin UI as a resource |
The packages
Core plus your provider is all you need:
| Core | Shiny.DocumentDb |
| Relational | Sqlite · Sqlite.SqlCipher · Sqlite.VectorSupport · PostgreSql · CockroachDb · SqlServer · MySql · MariaDb · Oracle · DuckDb |
| Client / embedded | LiteDb · IndexedDb |
| Document & NoSQL | MongoDb · CosmosDb · DocumentDb (Amazon) · AzureTable · DynamoDb · Redis · RavenDb · Firestore |
| Extensions | Geo · JsonSchema · Extensions.AI · OData · AspNetCore.OData · AppDataSync |
| Orleans | Orleans · Orleans.MongoDb · Orleans.CosmosDb |
| Aspire | Aspire.Hosting · Aspire.Client · Aspire.Orleans |
The admin UI isn’t a NuGet package — it’s ghcr.io/shinyorg/shiny-docdb-myadmin.
Links
- Docs — shinylib.net/documentdb
- Full v12 changelog — release notes
- Admin UI — ShinyDocDbMyAdmin
- JSON collections — json-collections
- Soft delete — soft-delete
- Interceptors — interceptors
- AOT setup — aot
- Provider comparison — providers
- GitHub — github.com/shinyorg/DocumentDb
There’s also a Claude Code skill in the repo that keeps an agent honest about the API surface, if you’re generating DocumentDb code.
Go break something. Preferably in a browser tab, where you can now see what broke.
comments powered by Disqus