Document DB
A lightweight, database-agnostic document store for .NET that turns your database into a schema-free JSON document database with LINQ querying, spatial/geo queries, vector / ANN search, and full AOT/trimming support. Store entire object graphs โ nested objects, child collections โ as JSON documents. No CREATE TABLE, no ALTER TABLE, no JOINs, no migrations. One API, multiple database providers.
Features
Section titled โFeaturesโ- Multi-provider โ SQLite, SQLCipher (encrypted SQLite), LiteDB, CosmosDB, MongoDB, DuckDB, IndexedDB (Blazor WASM), SQL Server, MySQL, PostgreSQL, and Oracle with a single API
- Zero schema, zero migrations โ store objects as JSON documents
- Fluent query builder โ
store.Query<User>().Where(u => u.Age > 30).OrderBy(u => u.Name).Paginate(0, 20).ToList()with full LINQ expression support for nested properties,Any(),Count(), string methods, null checks, and captured variables IAsyncEnumerable<T>streaming โ yield results one-at-a-time with.ToAsyncEnumerable()- Expression-based JSON indexes โ up to 30x faster queries on indexed properties
- SQL-level projections โ project into DTOs via
.Select()at the database level - Aggregates โ scalar
.Max(),.Min(),.Sum(),.Average()as terminal methods; aggregate projections with automatic GROUP BY viaSql.*markers; collection-level Sum, Min, Max, Average on child collections - Ordering โ
.OrderBy(u => u.Age)and.OrderByDescending(u => u.Name)on the fluent query builder - Pagination โ
.Paginate(offset, take)translates to SQLLIMIT/OFFSET - Table-per-type mapping โ
MapTypeToTable<T>()gives a document type its own dedicated table. Unmapped types share a configurable default table - Custom Id properties โ
MapTypeToTable<T>("table", x => x.MyProp)to combine with a dedicated table, orMapIdProperty<T>(x => x.MyProp)to override the Id while keeping the type in the default shared table - Document diffing โ
GetDiffcompares a modified object against the stored document and returns an RFC 6902JsonPatchDocument<T>with deep nested-object diffing - Surgical field updates โ
SetPropertyupdates a single JSON field without deserialization.RemovePropertystrips a field. Both support nested paths - JSON Merge Patch (Upsert) โ
Upsertuses RFC 7396json_patchto deep-merge a partial object into an existing document, preserving unset nullable fields. Inserts if the document doesnโt exist - Bulk operations โ
Query<T>().Where(...).ExecuteUpdate(x => x.Prop, value)and.ExecuteDelete()issue a single SQL statement against all matching documents โ no deserialization, no client-side loop - Typed Id lookups โ
Get,Remove,SetProperty, andRemovePropertyaccept the Id asobjectso you can pass aGuid,int,long, orstringdirectly. Unsupported types throwArgumentException - Full AOT/trimming support โ all
JsonTypeInfo<T>parameters are optional and auto-resolve from a configuredJsonSerializerContext. SetUseReflectionFallback = falseto catch missing registrations with clear exceptions - Optimistic concurrency โ
MapVersionProperty<T>(x => x.RowVersion)enables automatic version checking on update/upsert. Version is set to 1 on insert, checked and incremented on update. ThrowsConcurrencyExceptionon conflict. Works across all providers โ stored in the JSON blob with zero schema changes - Unit of work โ
CreateUnitOfWork()+SaveChanges()with automatic commit/rollback - Batch writes โ
BatchInsertinserts a collection in a single transaction with prepared command reuse, auto-generates IDs, and rolls back atomically on failure.BatchUpsert,BatchUpdate, andBatchRemove<T>(ids)apply many writes as one set operation (a single multi-rowINSERT โฆ ON CONFLICTdeep-merge on SQLite/DuckDB, oneBulkWrite/DeleteManyon MongoDB, parallel request waves on Cosmos, a singleDELETE โฆ IN (โฆ)on relational). All-or-nothing โ the first version conflict rolls the whole batch back - Spatial / geo queries โ
WithinRadius,WithinBoundingBox, andNearestNeighborswithGeoPointsupport. SQLite uses R*Tree; CosmosDB uses nativeST_DISTANCE/ST_WITHIN. Learn more - Vector / ANN search โ register an embedding property with
MapVectorProperty<T>(d => d.Embedding, dimensions: 1536, metric: VectorDistance.Cosine, indexKind: VectorIndexKind.Hnsw)and query withQuery<T>().Where(...).NearestVectors(query, k). Provider-native indexes: pgvector (PostgreSQL),VECTOR+ DiskANN (SQL Server 2025), nativeVECTOR+ HNSW/IVF (Oracle 23ai), embedding policy (CosmosDB),$vectorSearch(MongoDB Atlas),vssextension (DuckDB),sqlite-vec(SQLite). PlusAutoEmbedOnInsert<T>to plug inMicrosoft.Extensions.AI.IEmbeddingGeneratorand embed text automatically on every write. Learn more - Full-text search (all providers) โ
MapFullTextProperty<T>(a => a.Body)(or an array of paths) +store.FullTextSearch<T>("orleans persistence")for relevance-ranked search, returningFullTextResult<T>(Document+ normalizedScore) ordered by relevance, with an optional pre-filter and a fluentstore.Query<T>().Where(...).FullTextMatch("...")form. The native index is auto-created and engine-maintained: FTS5 (SQLite),tsvector+GIN (PostgreSQL),FULLTEXT(MySQL), Oracle Text, SQL Server Full-Text, theftsextension (DuckDB), full-text policy (CosmosDB),$text(MongoDB), and an in-memory TF-IDF fallback on LiteDB / IndexedDB. A type must be mapped before it can be searched. Learn more - Composite JSON indexes โ
CreateIndexAsync(ctx.User, u => u.Country, u => u.Age)builds a single B-tree across multiple JSON paths on SQLite, SQLCipher, PostgreSQL, MySQL, Oracle, DuckDB, and SQL Server. Learn more - Hot backup โ
Backupcopies the database to a file. Available onSqliteDocumentStore,SqlCipherDocumentStore, andLiteDbDocumentStore - Clear the whole store โ
((IDocumentMaintenance)store).ClearAll()wipes every document type plus temporal-history, spatial, and vector sidecars (test/dev resets) without touching the system catalogs. Implemented on the relationalDocumentStore(SQLite, SQL Server, PostgreSQL, MySQL, DuckDB, Oracle), MongoDB, and CosmosDB; the olderSqliteDocumentStore.ClearAllAsync()delegates to it - Database seeding โ register
IDocumentSeeders to populate initial data once at startup. Schema-free seeding is just idempotent writes, so seeders are provider-agnostic; run-once is versioned via aDocumentSeedMarker(bumpVersionto re-run). Wire withAddDocumentSeeder<T>()/AddDocumentSeeder(name, version, delegate)at host startup, or callDocumentSeedRunner.RunAsync(store, seeders)directly (e.g. on MAUI) - SQLCipher encryption โ separate
Shiny.DocumentDb.Sqlite.SqlCipherpackage with AES-256 encryption, password-aware backup, andRekeyAsyncto change the encryption key - Multi-tenancy โ two isolation strategies: shared-table (single database with automatic
TenantIdcolumn filtering) and tenant-per-database (separate database per tenant via lazy factory). Both resolve the current tenant via a user-implementedITenantResolver. Consumer code is unchanged โ tenant isolation is applied transparently - Change monitoring โ consume an
IAsyncEnumerable<DocumentChange<T>>of insert/update/remove/clear events withawait foreach (var c in store.NotifyOnChange<User>(ct)). Filter to a single document withWhenDocumentChanged<T>(id)or to the result set of a fluent query withquery.NotifyOnChange(). Buffered in aUnitOfWorkand emitted on commit. Learn more - Native change feeds โ
IChangeFeedDocumentStore.SubscribeChanges<T>observes all writers via the databaseโs own mechanism: PostgreSQLLISTEN/NOTIFYtriggers, SQL Server Change Tracking (optionally withSqlDependencyquery notifications), and Cosmos DB native Change Feed. Provisioning is automatic and idempotent - Temporal history (system-time versioning) โ
MapTemporal<T>(o => { o.Retention = ...; o.MaxVersions = ...; o.CaptureActor = ...; })opts a type into append-only versioning. Every Insert/Update/Upsert/Remove/SetProperty/RemoveProperty/BatchInsert records a snapshot to a per-type history sidecar. Read it back withHistory<T>(id),AsOf<T>(id, when),Restore<T>(id, version),GetDiffBetween<T>(id, from, to), plus fleet-wideAsOfAll<T>(when),ChangesByActor<T>(actor), andChangesBetween<T>(from, to)โ on theITemporalDocumentStorecapability interface, notIDocumentStore. Opt-in per type, on every provider (relational and document/NoSQL). Learn more - Global query filters โ register an
AddQueryFilter<T>(u => !u.IsDeleted)predicate thatโs automatically AND-applied to every query ofT, plusGet/Update/Remove/SetProperty/RemoveProperty/Clear/ExecuteUpdate/ExecuteDeleteand per-query change monitoring. Mirrors Entity Framework CoreโsHasQueryFilter, including named filters,IgnoreQueryFilters()/IgnoreQueryFilters("name"), and captured-variable semantics. Learn more - AI tool integration โ
Shiny.DocumentDb.Extensions.AIexposes document types asMicrosoft.Extensions.AItool functions for LLM agents. Per-type capability flags (ReadOnly,All), structured filter expressions, field visibility control, and page size caps. Learn more - Orleans persistence โ
Shiny.DocumentDb.Orleansprovides a full Microsoft Orleans stack โ grain storage, reminders, cluster membership, and grain directory โ on any DocumentDb backend (relational, MongoDB, or Cosmos) through oneIDocumentStoreabstraction. Because grain state is persisted as structured, queryable JSON, you can query grain state directly without activating grains (reporting, dashboards, ops tooling) and get a free audit trail of every mutation viaMapTemporal<T>. Learn more - Telemetry & diagnostics โ
Shiny.DocumentDb.Diagnosticswraps any provider withAddDocumentStoreInstrumentation()to emit OpenTelemetry-native metrics (db.client.operation.durationand friends) andActivitySourcetrace spans per operation โ CRUD, fluent-query terminals, temporal, and transactions (as parent spans). Built onSystem.Diagnostics.Metrics; zero-cost when nobody is listening. Learn more - JSON Schema validation โ
Shiny.DocumentDb.JsonSchemaattaches a JSON Schema (draft 2020-12) to a document type and validates the exact JSON about to be persisted just before the write.options.MapJsonSchema<Customer>(schemaJson)needs no DI (works with a hand-builtnew DocumentStore(options)); a failure throwsDocumentSchemaValidationExceptionwith field-level errors and rolls the write back. Enforces what the C# type canโt โmaxLength, ranges,pattern,enum,format. Learn more - OData query endpoints โ
Shiny.DocumentDb.OData+Shiny.DocumentDb.AspNetCore.ODataexpose a document type as an OData v4 entity set:$filter/$orderby/$top/$skip/$count/$selecttranslate onto the fluent query and run against any provider. Global query filters always apply underneath, and per-entity-setODataQueryPolicygovernance locks down public endpoints. Learn more - Offline-first sync โ
Shiny.DocumentDb.AppDataSyncmakes the store the local cache of an offline-first app that bidirectionally syncs to an HTTP backend viaShiny.Data.Sync.SyncDocumentStore(sync => sync.Sync<TodoItem>())turns an ordinary document type into a two-way synced one โ every local write is auto-enqueued to the outbox and every pulled server change is auto-applied back. Client-tier providers (SQLite, LiteDB, IndexedDB). Learn more - .NET Aspire integration โ
Shiny.DocumentDb.Aspire.Hosting/.Client/.Orleansmake the backend a deployment decision:builder.AddPostgresDocumentStore("orders").WithSeeder(...)in the AppHost picks the provider and gates seeding; the consuming service callsbuilder.AddDocumentStore("orders")for the keyed store wired with health checks + OpenTelemetry. Learn more
-
Install the NuGet packages
Install the core package plus your provider:
Each provider package includes the core
Shiny.DocumentDbpackage automatically.For dependency injection, also install the DI extensions package:
Terminal window dotnetaddpackageShiny.DocumentDb.Extensions.DependencyInjection -
Register with dependency injection:
usingShiny.DocumentDb;services.AddDocumentStore(opts=>{opts.DatabaseProvider =newSqliteDatabaseProvider("Data Source=mydata.db");});Just swap the provider for your database:
MongoDB uses its own options class โ register the store directly with the DI container:
builder.Services.AddSingleton(newMongoDbDocumentStoreOptions{ConnectionString ="mongodb://localhost:27017",DatabaseName ="mydb"});builder.Services.AddSingleton<IDocumentStore, MongoDbDocumentStore>();For multiple databases, register named stores using .NET keyed services:
services.AddDocumentStore("users", opts=>{opts.DatabaseProvider =newSqliteDatabaseProvider("Data Source=users.db");});services.AddDocumentStore("analytics", opts=>{opts.DatabaseProvider =newPostgreSqlDatabaseProvider("Host=...");});Inject via
[FromKeyedServices("name")]or resolve dynamically withIDocumentStoreProvider:publicclassMyService([FromKeyedServices("users")] IDocumentStoreuserStore,[FromKeyedServices("analytics")] IDocumentStoreanalyticsStore) { }// Or dynamically:publicclassMyService(IDocumentStoreProviderstores){voidDoWork() => stores.GetStore("users").Insert(...);}For multi-tenant applications, two isolation strategies are available:
// Shared-table: single database, automatic TenantId column filteringservices.AddSingleton<ITenantResolver, MyTenantResolver>();services.AddDocumentStore(opts=>{opts.DatabaseProvider =newPostgreSqlDatabaseProvider("Host=...");}, multiTenant: true);// ...or a named/keyed shared-table store (resolve with [FromKeyedServices("orders")]):services.AddDocumentStore("orders", opts=>{opts.DatabaseProvider =newPostgreSqlDatabaseProvider("Host=...");}, multiTenant: true);// Tenant-per-database: separate database per tenant (scoped IDocumentStore)services.AddSingleton<ITenantResolver, MyTenantResolver>();services.AddMultiTenantDocumentStore(tenantId=>newDocumentStoreOptions{DatabaseProvider =newSqliteDatabaseProvider($"Data Source={tenantId}.db")});Both require an
ITenantResolverimplementation:publicclassMyTenantResolver(IHttpContextAccessorhttp) : ITenantResolver{publicstringGetCurrentTenant()=> http.HttpContext?.User.FindFirst("tenant_id")?.Value??thrownewInvalidOperationException("No tenant context");}Or instantiate directly (no DI needed):
-
Inject
IDocumentStoreand start using it:publicclassMyService(IDocumentStorestore){publicasyncTaskSaveUser(Useruser){await store.Insert(user); // Id auto-generated for Guid/int/long; string Ids must be set}publicasyncTask<User?> GetUser(stringid){returnawait store.Get<User>(id);}publicasyncTask<IReadOnlyList<User>> GetActiveUsers(){returnawait store.Query<User>().Where(u=> u.IsActive).OrderBy(u=> u.Name).ToList();}}
Configuration Options
Section titled โConfiguration Optionsโ| Property | Type | Default | Description |
|---|---|---|---|
DatabaseProvider | IDatabaseProvider (required) | โ | The database provider to use (e.g. SqliteDatabaseProvider, SqlCipherDatabaseProvider, SqlServerDatabaseProvider, MySqlDatabaseProvider, PostgreSqlDatabaseProvider, DuckDbDatabaseProvider). LiteDB, CosmosDB, MongoDB, and IndexedDB use their own options classes. |
TableName | string | "documents" | Default table name for all document types not mapped via MapTypeToTable |
TypeNameResolution | TypeNameResolution | ShortName | How type names are stored (ShortName or FullName) |
JsonSerializerOptions | JsonSerializerOptions? | null | JSON serialization settings. When a JsonSerializerContext is attached as the TypeInfoResolver, all methods auto-resolve type info from the context |
UseReflectionFallback | bool | true | When false, throws InvalidOperationException if a type canโt be resolved from the configured TypeInfoResolver instead of falling back to reflection. Recommended for AOT deployments |
Logging | Action<string>? | null | Callback invoked with every SQL statement executed |
TenantIdAccessor | Func<string>? | null | When set, enables shared-table multi-tenancy. All queries are filtered by TenantId and all inserts include the TenantId value. A dedicated TenantId column and index are created automatically |
Table-Per-Type Mapping
Section titled โTable-Per-Type MappingโBy default all document types share a single table. Use MapTypeToTable to give a type its own dedicated table. Tables are lazily created on first use. Two types cannot map to the same custom table.
varstore=newDocumentStore(newDocumentStoreOptions{DatabaseProvider =newSqliteDatabaseProvider("Data Source=mydata.db"),TableName ="docs"// change the default table name (optional)}.MapTypeToTable<Order>("orders") // explicit table name.MapTypeToTable<AuditLog>() // auto-derived table name "AuditLog"// User stays in the default "docs" table);Custom Id property
Section titled โCustom Id propertyโBy default every document type must have a property named Id. Override that with a custom property using either MapTypeToTable<T>(...) (combined with a dedicated table) or MapIdProperty<T>(...) (the type stays in the default shared table). The two are independent โ use either, both, or neither.
varstore=newDocumentStore(newDocumentStoreOptions{DatabaseProvider =newSqliteDatabaseProvider("Data Source=mydata.db")}// Dedicated table + custom Id.MapTypeToTable<Sensor>("sensors", s=> s.DeviceKey) // Guid DeviceKey as Id.MapTypeToTable<Tenant>("tenants", t=> t.TenantCode) // string TenantCode as Id// Default shared table + custom Id.MapIdProperty<BlogPost>(p=> p.Slug) // string Slug as Id);MapTypeToTable and MapIdProperty overloads
Section titled โMapTypeToTable and MapIdProperty overloadsโ| Overload | Description |
|---|---|
MapTypeToTable<T>() | Auto-derive table name from type name |
MapTypeToTable<T>(string tableName) | Explicit table name |
MapTypeToTable<T>(Expression<Func<T, object>> idProperty) | Auto-derive table + custom Id |
MapTypeToTable<T>(string tableName, Expression<Func<T, object>> idProperty) | Explicit table + custom Id |
MapIdProperty<T>(Expression<Func<T, object>> idProperty) | Custom Id only โ type stays in the default shared table |
MapIdProperty<T>(string propertyName) | AOT-safe string overload |
All overloads return DocumentStoreOptions for fluent chaining. Duplicate table names throw InvalidOperationException.
DI Registration with Table Mapping
Section titled โDI Registration with Table Mappingโservices.AddDocumentStore(opts=>{opts.DatabaseProvider =newSqliteDatabaseProvider("Data Source=mydata.db");opts.MapTypeToTable<User>();opts.MapTypeToTable<Order>("orders");opts.MapTypeToTable<Sensor>("sensors", s=> s.DeviceKey);});AI Coding Assistant
Section titled โAI Coding AssistantโStep 1 โ Add the marketplace:
claude plugin marketplace add shinyorg/skills Step 2 โ Install the plugin:
claude plugin install shiny-data@shiny Step 1 โ Add the marketplace:
copilot plugin marketplace add https://github.com/shinyorg/skills Step 2 โ Install the plugin:
copilot plugin install shiny-data@shiny 