Codync
免费开源的 1:1 Grok Bot 与 Muse 替代方案。可在 iPhone、Mac 或 Linux 桌面上将 Claude 代码、Codex、Cursor 以及 40 多个编码代理作为持久机器人使用。支持群组聊天、线程、应用。
AI 翻译
尚未收集仓库信息。
尚未检测到更新。GitHub 发布版本后会显示在这里。
制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗A lightweight, multi-provider document store for .NET that turns relational databases into a schema-free JSON document database with LINQ querying, spatial/geo queries, and full AOT/trimming support. Supports SQLite, SQLCipher (encrypted SQLite), LiteDB, CosmosDB, MongoDB, Amazon DocumentDB (MongoDB-compatible), Redis (Redis Stack), RavenDB, Google Firestore, Azure Table Storage (and Cosmos DB Table API), Amazon DynamoDB, DuckDB, IndexedDB (Blazor WASM), MySQL, MariaDB, SQL Server, PostgreSQL, CockroachDB, and Oracle.
CREATE TABLE, no ALTER TABLE, and no JOINs needed to read a nested graph back.Any/All over a collection, collection aggregates, array GroupBy — MariaDB has no JSON_TABLE, so these throw a clear error); CockroachDB keeps native spatial, full-text, and (pgvector-compatible) vector search, and scopes away change-feed/bulk-copy/soundex. Both are wired into the Aspire integration. Azure Table and DynamoDB are schema-free key-partitioned NoSQL stores (AddAzureTableDocumentStore(...) / AddDynamoDbDocumentStore(...)) that map the library's (typeName, id) identity onto the store's native partition/sort keys. Rich LINQ queries evaluate client-side after a single-partition scan; promote hot query paths with cfg.MapIndexedProperty(x => x.Status) and predicates over that property (plus the string Query/Count/ToQueryString overloads — OData $filter on Table, PartiQL on DynamoDB) push down server-side. Optimistic concurrency uses the native ETag / conditional-write; both support in-process change observation (IObservableDocumentStore) and DynamoDB adds a native DynamoDB Streams change feed (IChangeFeedDocumentStore). Int/Long Id auto-generation is unsupported (use Guid/string, or assign the Id) — no spatial/vector/full-text/temporal on those two. Four more document-native providers ship alongside: Redis (AddRedisDocumentStore(...), requires Redis Stack — RedisJSON + RediSearch) with server-side full-text/vector(KNN)/geo, cfg.MapIndexedProperty push-down to FT.SEARCH, a keyspace-notification change feed, and the only NoSQL backend where Int/Long Id auto-gen works (atomic ); () storing an opaque System.Text.Json envelope and evaluating LINQ client-side over immediately-consistent id-prefix streams (RQL ); () with native-map storage (single-field push-down + full-scan fallback for missing composite indexes), native cursor pagination, transaction-guarded CAS, and a real per-query change feed via snapshot listeners; and (), a thin MongoDB-provider subclass with TLS + defaults that throws for the features DocumentDB lacks ( full-text, Atlas ).| Shiny.DocumentDb | Microsoft.Data.Sqlite (raw ADO.NET) | sqlite-net-pcl | |
|---|---|---|---|
| Schema management | Zero — just store objects | You write every CREATE TABLE, ALTER TABLE, migration | Auto-creates flat tables from POCOs |
| Database providers | SQLite, LiteDB, CosmosDB, MongoDB, Azure Table, DynamoDB, DuckDB, IndexedDB, MySQL, SQL Server, PostgreSQL, Oracle | SQLite only | SQLite only |
| Nested objects & child collections | Stored and queried as a single JSON document | Must design normalized tables, write JOINs, manage foreign keys | No support — flat columns only, child collections require separate tables + manual joins |
| LINQ queries on nested data | store.Query<Order>().Where(o => o.Lines.Any(l => l.Price > 10)).ToList() | Hand-written json_extract SQL | Not possible on nested data |
| AOT / trimming | First-class optional JsonTypeInfo<T> on every API | Manual — you control all SQL | Relies on reflection; no AOT support |
| Migrations | Not needed — schema-free JSON | You own every migration | You own every migration |
| Projections | SQL-level json_object projections via .Select() | Manual SQL | Not available |
| Unit of work | store.OpenSession() (IDocumentSession) + SaveChanges() | Manual BeginTransaction + Commit/Rollback | SaveChanges (change tracker) |
| JSON property indexes | store.CreateIndexAsync<User>(u => u.Name, ctx.User) — LINQ expression indexes on json_extract | Manual CREATE INDEX on json_extract | Column indexes only |
| Best fit | Object graphs, nested data, rapid prototyping, settings stores, caches | Full SQL control, complex reporting queries, performance-critical bulk ops |
In short: If your data has nested objects or child collections (orders with line items, users with addresses, configs with nested sections), this library lets you store and query the entire object graph with a single call — no table design, no JOINs, no migrations. For flat, single-table CRUD on simple POCOs, sqlite-net-pcl or raw ADO.NET may be simpler.
Entity Framework Core is a natural choice for server-side .NET, but it becomes a liability on .NET MAUI platforms (iOS, Android, Mac Catalyst). This library is purpose-built for the constraints mobile and desktop apps actually face.
[RequiresDynamicCode] and [RequiresUnreferencedCode] attributes throughout its public API. On iOS, where Apple prohibits JIT compilation entirely, this is a non-starter for fully native AOT deployments.Add-Migration, Update-Database, __EFMigrationsHistory) adds complexity with no real benefit — there is no DBA, no staging environment, no rollback plan. A schema-free document store eliminates migrations entirely.Microsoft.EntityFrameworkCore, its SQLite provider, design-time packages, and their transitive dependencies. This increases app bundle size — a real concern when app stores enforce download size limits and users expect fast installs.| Concern | EF Core | Shiny.DocumentDb |
|---|---|---|
| AOT / trimming | Reflection-heavy; no AOT support | Every API has optional JsonTypeInfo<T>; zero reflection required |
| Database support | Many providers | SQLite, LiteDB, CosmosDB, MongoDB, DuckDB, IndexedDB, MySQL, SQL Server, PostgreSQL, Oracle |
| Migrations | Required for every schema change | Not needed — schema-free JSON storage |
| Nested objects | Normalized tables, foreign keys, JOINs | Single document, single write, single read |
| App bundle size | Large dependency tree | Core package + one provider dependency |
| Startup time | DbContext model building, migration checks | Open connection and go |
| Offline / sync patterns | Complex change tracking | Store and retrieve document snapshots directly |
Ahead-of-Time compilation is not optional on Apple platforms — iOS, iPadOS, tvOS, and Mac Catalyst all prohibit JIT at the OS level. Android does not prohibit JIT, but AOT deployment (PublishAot or AndroidEnableProfiledAot) delivers measurably faster startup and lower memory usage, both of which directly affect user experience.
The .NET trimmer removes unreferenced code to shrink the app binary. Libraries that depend on reflection break under trimming because the trimmer cannot statically determine which types and members are accessed at runtime. This forces developers to either disable trimming (larger binaries) or maintain complex trimmer XML files.
This library avoids both problems:
JsonSerializerContext pattern generates serialization code at compile time. The trimmer can see every type that will be serialized, and the AOT compiler can compile every code path ahead of time.Expression.Compile(), no Reflection.Emit, no dynamic delegates.OnModelCreating that discovers entity types and relationships through reflection at startup.DbContext-style API that fits MAUI's lifetime modelIf you like EF Core's typed DbContext, the optional DocumentContext gives you the same shape — db.Users.Where(...), db.Orders.Insert(...) — without the AOT, migration, and bundle-size costs above. MAUI has no per-request DI scope, which is exactly why EF Core recommends IDbContextFactory<T> there: register services.AddAppContextFactory(...), inject the singleton IDocumentContextFactory<AppContext> anywhere (even into a singleton page or view-model), and call factory.Create() per operation. Unlike a DbContext, the created context is a stateless facade over the shared, thread-safe store — cheap to create and needing no disposal. (services.AddAppContext(...) keeps the scoped registration for ASP.NET Core back-ends.)
If you are building a .NET MAUI app and need local data persistence, this library gives you a queryable document store that works under full AOT and trimming without compromise.
Measured with BenchmarkDotNet v0.15.8 on Apple M5 Pro, .NET 10.0.8, macOS. Four-way comparison: Shiny.DocumentDb vs sqlite-net-pcl vs EF Core (SQLite provider, run with pre-compiled EF.CompileAsyncQuery + AsNoTracking reads — its fastest configuration) vs Dapper (hand-written SQL over a raw Microsoft.Data.Sqlite connection — the micro-ORM "floor"). Full source in benchmarks/.
| Method | Count | Mean |
|---|---|---|
| DocumentStore Insert | 10 | 389 µs |
| EF Core Insert | 10 | 877 µs |
| Dapper Insert | 10 | 1.70 ms |
| sqlite-net Insert | 10 | 1.78 ms |
| DocumentStore Insert | 100 | 3.54 ms |
| EF Core Insert | 100 | 7.28 ms |
| Dapper Insert | 100 | 17.16 ms |
| sqlite-net Insert | 100 | 17.65 ms |
| DocumentStore Insert | 1000 | 35.14 ms |
| EF Core Insert | 1000 | 93.02 ms |
| sqlite-net Insert | 1000 | 260.62 ms |
| Dapper Insert | 1000 | 385.10 ms |
The document store writes one row per object; the others pay a round trip per row. DocumentStore stays ~2-3x ahead of EF Core. sqlite-net and Dapper (hand-written SQL over raw ADO.NET) trail because each un-batched insert auto-commits to disk — at 1000 rows that per-row latency dominates and gets noisy (high variance). For bulk writes, use batch inserts (below).
| Method | Count | Mean | Allocated |
|---|---|---|---|
| DocumentStore BatchInsert | 10 | 184 µs | 17.20 KB |
| sqlite-net InsertAllAsync | 10 | 320 µs | 5.23 KB |
| Dapper batch (transaction) | 10 | 346 µs | 16.32 KB |
| EF Core AddRange | 10 | 552 µs | 95.38 KB |
| sqlite-net InsertAllAsync | 100 | 437 µs | 45.08 KB |
| DocumentStore BatchInsert | 100 | 542 µs | 140.55 KB |
| Dapper batch (transaction) | 100 | 546 µs | 143.59 KB |
| EF Core AddRange | 100 | 3.41 ms | 864.96 KB |
| sqlite-net InsertAllAsync | 1000 | 1.52 ms | 438.83 KB |
| Dapper batch (transaction) | 1000 | 2.80 ms | 1,416.24 KB |
| DocumentStore BatchInsert | 1000 | 8.72 ms | 1,370.61 KB |
| EF Core AddRange | 1000 | 13.63 ms | 8,077.58 KB |
DocumentStore leads at small batches (prepared-command reuse in one transaction). At 1000 items sqlite-net's simpler row structure takes the lead, with Dapper's transactional batch close behind; both raw-SQL contenders stay far ahead of EF Core, whose change tracker allocates 5–40x more.
| Method | Mean | Allocated |
|---|---|---|
| DocumentStore GetById | 2.73 µs | 2.55 KB |
| Dapper GetById | 4.96 µs | 1.98 KB |
| EF Core GetById (compiled) | 8.69 µs | 8.34 KB |
| sqlite-net GetById | 10.35 µs | 3.70 KB |
| Method | Count | Mean | Allocated |
|---|---|---|---|
| Dapper GetAll | 100 | 36.32 µs | 21.40 KB |
| EF Core GetAll (compiled) | 100 | 39.14 µs | 41.48 KB |
| sqlite-net GetAll | 100 | 46.12 µs | 28.38 KB |
| DocumentStore GetAll | 100 | 50.87 µs | 55.90 KB |
| Dapper GetAll | 1000 | 315.88 µs | 197.19 KB |
| EF Core GetAll (compiled) | 1000 | 317.24 µs | 343.83 KB |
| sqlite-net GetAll | 1000 | 396.97 µs | 246.35 KB |
| DocumentStore GetAll | 1000 | 500.26 µs | 541.06 KB |
| Method | Mean | Allocated |
|---|---|---|
| Dapper Query | 21.47 µs | 2.05 KB |
| EF Core Query (compiled) | 25.13 µs | 8.33 KB |
| sqlite-net Query | 31.19 µs | 5.33 KB |
| DocumentStore Query | 160.52 µs | 5.09 KB |
For flat single-column reads the relational contenders — Dapper fastest, then EF Core and sqlite-net — read native indexed columns directly while the document store uses
json_extract. Add a JSON property index and the gap closes dramatically (see Index impact). The document store's architecture pays off on nested data below.
This is where the document store architecture pays off. sqlite-net needs 3 tables, 6 inserts per order, and 3 queries per read with manual rehydration. EF Core models the graph with related entities — read here with Include plus pre-compiled, no-tracking queries — but still pays for multi-table JOINs and graph materialization. Dapper issues hand-written SQL across the same 3 tables with manual rehydration (the relational floor). The document store stores and loads the entire object graph as one JSON document.
| Method | Count | Mean |
|---|---|---|
| DocumentStore Insert (nested) | 10 | 417 µs |
| EF Core Insert (3 tables) | 10 | 4.94 ms |
| Dapper Insert (3 tables) | 10 | 11.43 ms |
| sqlite-net Insert (3 tables) | 10 | 11.73 ms |
| DocumentStore Insert (nested) | 100 | 3.63 ms |
| EF Core Insert (3 tables) | 100 | 24.75 ms |
| sqlite-net Insert (3 tables) | 100 | 123.38 ms |
| Dapper Insert (3 tables) | 100 | 174.28 ms |
| DocumentStore Insert (nested) | 1000 | 36.31 ms |
| EF Core Insert (3 tables) | 1000 | 648.55 ms |
| Dapper Insert (3 tables) | 1000 | 1.99 s |
| sqlite-net Insert (3 tables) | 1000 | 2.18 s |
Per-order inserts are un-batched (6 statements per order, each auto-committing), so the row-at-a-time contenders (sqlite-net, Dapper) get slow and high-variance at scale — the single-document write is the whole point.
| Method | Mean | Allocated |
|---|---|---|
| DocumentStore GetById (nested) | 3.47 µs | 4.43 KB |
| Dapper GetById (3 queries) | 18.56 µs | 6.99 KB |
| sqlite-net GetById (3 queries) | 27.84 µs | 16.05 KB |
| EF Core GetById (Include, compiled) | 32.03 µs | 15.64 KB |
| Method | Count | Mean | Allocated |
|---|---|---|---|
| DocumentStore GetAll (nested) | 100 | 127.2 µs | 244.10 KB |
| sqlite-net GetAll (3 tables + rehydrate) | 100 | 205.7 µs | 158.92 KB |
| Dapper GetAll (3 tables + rehydrate) | 100 | 253.6 µs | 192.94 KB |
| EF Core GetAll (Include, compiled) | 100 | 1.13 ms | 734.54 KB |
| DocumentStore GetAll (nested) | 1000 | 1.36 ms | 2,423.80 KB |
| sqlite-net GetAll (3 tables + rehydrate) | 1000 | 1.76 ms | 1,438.35 KB |
| Dapper GetAll (3 tables + rehydrate) | 1000 | 2.45 ms | 1,852.05 KB |
| EF Core GetAll (Include, compiled) | 1000 | 11.88 ms | 7,273.61 KB |
| Method | Mean | Allocated |
|---|---|---|
| DocumentStore Query (nested, by status) | 1.01 ms | 1,215.39 KB |
| sqlite-net Query (3 tables + rehydrate) | 1.42 ms | 1,013.48 KB |
| Dapper Query (3 tables + rehydrate) | 2.13 ms | 1,461.38 KB |
| EF Core Query (Include, compiled) | 5.90 ms | 3,641.24 KB |
For nested data the document store is the clear winner: 6–60x faster inserts and 2–9x faster reads than the relational contenders, because it stores and retrieves the entire object graph in a single operation instead of multiple table writes and JOINs. Even Dapper — hand-written SQL, the relational floor — stays 2–9x behind on nested reads. EF Core's change tracking and graph materialization also make it the heaviest allocator by a wide margin.
JSON property indexes (CreateIndexAsync) dramatically speed up equality queries by letting SQLite use a B-tree lookup instead of scanning every row with json_extract.
| Method | Mean | Allocated |
|---|---|---|
| Query without index | 159.33 µs | 5.33 KB |
| Query with index | 6.03 µs | 5.33 KB |
~26x faster — the indexed query resolves in microseconds because SQLite uses the partial index directly.
| Method | Mean | Allocated |
|---|---|---|
| Nested query without index | 660.5 µs | 487.34 KB |
| Nested query with index | 257.7 µs | 487.34 KB |
~2.6x faster — the index eliminates the full table scan, but read + deserialize time for ~200 matching documents dominates. Indexes give the biggest wins on selective queries that return few results.
Streaming yields results one-at-a-time without building an intermediate List<T>. Throughput is comparable; the benefit is reduced peak memory and eliminating Gen1 GC pressure at larger scales.
| Method | Count | Mean | Gen1 | Allocated |
|---|---|---|---|---|
| ToList (buffered) | 100 | 51.00 µs | 0.61 | 55.90 KB |
| ToAsyncEnumerable (streaming) | 100 | 51.44 µs | — | 53.83 KB |
| ToList (buffered) | 1000 | 488.77 µs | 31.25 | 541.06 KB |
| ToAsyncEnumerable (streaming) | 1000 | 496.40 µs | — | 524.92 KB |
| Method | Count | Mean | Gen1 | Allocated |
|---|---|---|---|---|
| ToList nested (buffered) | 100 | 119.0 µs | 7.20 | 244.10 KB |
| ToAsyncEnumerable nested (streaming) | 100 | 118.1 µs | 0.24 | 242.03 KB |
| ToList nested (buffered) | 1000 | 1.25 ms | 109.38 | 2,423.80 KB |
| ToAsyncEnumerable nested (streaming) | 1000 | 1.16 ms | 1.95 | 2,407.66 KB |
| Method | Mean | Gen1 | Allocated |
|---|---|---|---|
| Query Where ToList (buffered) | 998.1 µs | 74.22 | 1.19 MB |
| Query Where ToAsyncEnumerable (streaming) | 967.2 µs | — | 1.18 MB |
Streaming eliminates Gen1 GC collections entirely at scale. Throughput is within ~2% of buffered. Use streaming when you process results incrementally rather than needing the full list upfront.
Install the core package plus the provider for your database:
# SQLite (mobile, embedded, local)
dotnet add package Shiny.DocumentDb.Sqlite
# sqlite-vec native binaries + auto-extension registration for vector search on iOS/Android/desktop
dotnet add package Shiny.DocumentDb.Sqlite.VectorSupport
# Microsoft.Extensions.VectorData connector — use the store as a MEVD VectorStore
dotnet add package Shiny.DocumentDb.Extensions.VectorData
# SQLCipher (encrypted SQLite)
dotnet add package Shiny.DocumentDb.Sqlite.SqlCipher
# MySQL
dotnet add package Shiny.DocumentDb.MySql
# SQL Server
dotnet add package Shiny.DocumentDb.SqlServer
# PostgreSQL
dotnet add package Shiny.DocumentDb.PostgreSql
# Oracle (23ai+)
dotnet add package Shiny.DocumentDb.Oracle
# LiteDB
dotnet add package Shiny.DocumentDb.LiteDb
# CosmosDB
dotnet add package Shiny.DocumentDb.CosmosDb
# MongoDB
dotnet add package Shiny.DocumentDb.MongoDb
# DuckDB (embedded analytical)
dotnet add package Shiny.DocumentDb.DuckDb
# IndexedDB (Blazor WebAssembly)
dotnet add package Shiny.DocumentDb.IndexedDb
Dependency-injection registration (AddDocumentStore, AddDocumentContext, seeding) and OpenTelemetry
instrumentation ship in the core Shiny.DocumentDb package — no separate package to install.
// SQLite
using Shiny.DocumentDb.Sqlite;
var store = new DocumentStore(new DocumentStoreOptions
{
DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
});
// SQLCipher (encrypted SQLite)
using Shiny.DocumentDb.Sqlite.SqlCipher;
var store = new DocumentStore(new DocumentStoreOptions
{
DatabaseProvider = new SqlCipherDatabaseProvider("encrypted.db", "mySecretKey")
});
// MySQL
using Shiny.DocumentDb.MySql;
var store = new DocumentStore(new DocumentStoreOptions
{
DatabaseProvider = new MySqlDatabaseProvider("Server=localhost;Database=mydb;User=root;Password=pass")
});
// SQL Server
using Shiny.DocumentDb.SqlServer;
var store = new DocumentStore(new DocumentStoreOptions
{
DatabaseProvider = new SqlServerDatabaseProvider("Server=localhost;Database=mydb;Trusted_Connection=true")
});
// PostgreSQL
using Shiny.DocumentDb.PostgreSql;
var store = new DocumentStore(new DocumentStoreOptions
{
DatabaseProvider = new PostgreSqlDatabaseProvider("Host=localhost;Database=mydb;Username=postgres;Password=pass")
});
// Oracle (requires Oracle Database 23ai or later)
using Shiny.DocumentDb.Oracle;
var store = new DocumentStore(new DocumentStoreOptions
{
DatabaseProvider = new OracleDatabaseProvider("User Id=myuser;Password=pass;Data Source=localhost:1521/FREEPDB1")
});
// LiteDB
using Shiny.DocumentDb.LiteDb;
var store = new LiteDbDocumentStore(new LiteDbDocumentStoreOptions
{
ConnectionString = "Filename=mydata.db"
});
// CosmosDB
using Shiny.DocumentDb.CosmosDb;
var store = new CosmosDbDocumentStore(new CosmosDbDocumentStoreOptions
{
ConnectionString = "AccountEndpoint=https://...;AccountKey=...",
DatabaseName = "mydb",
ContainerName = "documents"
});
// MongoDB
using Shiny.DocumentDb.MongoDb;
var store = new MongoDbDocumentStore(new MongoDbDocumentStoreOptions
{
ConnectionString = "mongodb://localhost:27017",
DatabaseName = "mydb"
});
// DuckDB (embedded analytical store)
using Shiny.DocumentDb.DuckDb;
var store = new DocumentStore(new DocumentStoreOptions
{
DatabaseProvider = new DuckDbDatabaseProvider("Data Source=mydata.duckdb")
});
// IndexedDB (Blazor WebAssembly)
using Shiny.DocumentDb.IndexedDb;
// Requires IJSRuntime from DI — use the DI extension method below
Note:
SqliteDocumentStoreandSqlCipherDocumentStoreare still available as convenience wrappers that extendDocumentStore. They accept a connection string directly:new SqliteDocumentStore("Data Source=mydata.db")ornew SqlCipherDocumentStore("encrypted.db", "mySecretKey").
| Property | Type | Default | Description |
|---|---|---|---|
DatabaseProvider | IDatabaseProvider | (required) | The database provider to use (e.g., SqliteDatabaseProvider, MySqlDatabaseProvider, SqlServerDatabaseProvider, PostgreSqlDatabaseProvider, OracleDatabaseProvider, DuckDbDatabaseProvider). LiteDB, CosmosDB, and MongoDB use their own options classes instead. |
TypeNameResolution | TypeNameResolution | ShortName | How type names are stored — ShortName (e.g. User) or FullName (e.g. MyApp.Models.User) |
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 |
TableName | string | "documents" | Name of the default shared document table. Types that do not set cfg.Table are stored here |
SkipTableInitialization | bool | false | When true, never issues the lazy CREATE TABLE IF NOT EXISTS / index DDL that otherwise runs once per table on first touch — including on reads. For a read replica, an account without DDL rights, or a tool pointed at a database it must not change. The table must already exist |
Logging | Action<string>? | null | Callback invoked with every SQL statement executed |
AddDocumentStore (in the core Shiny.DocumentDb package) registers IDocumentStore as a singleton:
using Shiny.DocumentDb;
// SQLite
services.AddDocumentStore(opts =>
{
opts.DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db");
});
// SQLCipher (encrypted SQLite)
services.AddDocumentStore(opts =>
{
opts.DatabaseProvider = new SqlCipherDatabaseProvider("encrypted.db", "mySecretKey");
});
// SQL Server
services.AddDocumentStore(opts =>
{
opts.DatabaseProvider = new SqlServerDatabaseProvider("Server=localhost;Database=mydb;Trusted_Connection=true");
});
// MySQL
services.AddDocumentStore(opts =>
{
opts.DatabaseProvider = new MySqlDatabaseProvider("Server=localhost;Database=mydb;User=root;Password=pass");
});
// PostgreSQL
services.AddDocumentStore(opts =>
{
opts.DatabaseProvider = new PostgreSqlDatabaseProvider("Host=localhost;Database=mydb;Username=postgres;Password=pass");
});
// Oracle (requires Oracle Database 23ai or later)
services.AddDocumentStore(opts =>
{
opts.DatabaseProvider = new OracleDatabaseProvider("User Id=myuser;Password=pass;Data Source=localhost:1521/FREEPDB1");
});
// DuckDB (embedded analytical)
services.AddDocumentStore(opts =>
{
opts.DatabaseProvider = new DuckDbDatabaseProvider("Data Source=mydata.duckdb");
});
// Full options configuration
services.AddDocumentStore(opts =>
{
opts.DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db");
opts.TypeNameResolution = TypeNameResolution.FullName;
opts.JsonSerializerOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
});
Register multiple stores by name using .NET keyed services:
services.AddDocumentStore("users", opts =>
{
opts.DatabaseProvider = new SqliteDatabaseProvider("Data Source=users.db");
});
services.AddDocumentStore("analytics", opts =>
{
opts.DatabaseProvider = new PostgreSqlDatabaseProvider("Host=...");
});
// Inject via keyed services attribute
public class MyService(
[FromKeyedServices("users")] IDocumentStore userStore,
[FromKeyedServices("analytics")] IDocumentStore analyticsStore) { }
// Or resolve dynamically via IDocumentSessionFactory
public class MyService(IDocumentSessionFactory stores)
{
void DoWork() => stores.GetStore("users").Insert(...);
}
By default all document types share a single table ("documents"). You can map specific types to dedicated tables while unmapped types continue using the shared table.
var options = new DocumentStoreOptions
{
DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
};
options.ConfigureDocument<User>(cfg => cfg.Table = cfg.TypeName); // table named after the type
options.ConfigureDocument<Order>(cfg => cfg.Table = "orders"); // explicit table name
var store = new DocumentStore(options);
// Users → "User" table, Orders → "orders" table, everything else → "documents"
A custom table is exclusive to one document type — pointing a second type at the same name throws, because
cfg.Table means "give this type a place of its own". To put several types in the same table, leave
cfg.Table unset on all of them and set TableName; every type lands there, discriminated by TypeName.
Document types can use an alternate property as the document Id instead of the default Id. The Id property must be Guid, int, long, or string. Custom Ids can be combined with cfg.Table, or used on their own with cfg.MapIdProperty to keep the type in the default shared table.
var options = new DocumentStoreOptions
{
DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
};
// Dedicated table + custom Id
options.ConfigureDocument<Customer>(cfg => { cfg.Table = "customers"; cfg.MapIdProperty(c => c.CustomerId); });
options.ConfigureDocument<Sensor>(cfg => { cfg.Table = "sensors"; cfg.MapIdProperty(s => s.DeviceKey); });
// Default shared table + custom Id
options.ConfigureDocument<BlogPost>(cfg => cfg.MapIdProperty(p => p.Slug));
var store = new DocumentStore(options);
Auto-generation rules still apply — Guid and numeric Ids are auto-generated when default, and the value is written back to the property after insert.
Beyond the built-in Guid/int/long/string, register a converter with MapIdType to use any Id CLR type — a Ulid, or a strongly-typed wrapper such as record struct OrderId(Guid Value). The Id is still stored as a string in every provider (no schema/on-disk change); the converter just defines how it round-trips. Purely additive — the built-in types need no registration and behave exactly as before.
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
}
var store = new DocumentStore(new DocumentStoreOptions
{
DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
}
.MapIdType(
toString: (OrderId id) => id.Value.ToString("N"),
parse: s => new OrderId(Guid.ParseExact(s, "N")),
isDefault: id => id.Value == Guid.Empty, // when to auto-generate on Insert
generate: OrderId.New) // optional; omit to require explicit Ids
);
// Insert/Get/Update/Remove all accept the strongly-typed Id
var order = new Order { Customer = "Alice" }; // class Order { public OrderId Id { get; set; } … }
await store.Insert(order); // Id auto-generated
var fetched = await store.Get<Order>(order.Id);
A DocumentIdConverter<TId> base class is available for reusable converters (ToStorageString / FromStorageString / IsDefault / TryGenerate), and MapIdType is on every provider's options. Because the Id also lives inside the JSON Data blob, give a custom Id type a matching System.Text.Json converter so LINQ predicates on the Id line up with the stored form.
For sortable Guid Ids without any extra dependency, call options.UseGuidV7Ids() — Guid Ids then auto-generate as time-ordered version 7 GUIDs (Guid.CreateVersion7()) instead of random v4. Storage format is unchanged, so it is a drop-in for existing data. (long is also a built-in Id type if you just want a sequential integer key.)
| Member | Description |
|---|---|
cfg.Table = "orders" | Explicit table name |
cfg.Table = cfg.TypeName | Table named after the type |
cfg.MapIdProperty(x => x.MyKey) | Custom Id property |
cfg.MapIdProperty("MyKey") | AOT-safe string overload of the above |
ConfigureDocument<T> can be called more than once for the same typeArgumentExceptionFor AOT/trimming compatibility, create a source-generated JSON context:
[JsonSerializable(typeof(User))]
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(Address))]
[JsonSerializable(typeof(OrderLine))]
public partial class AppJsonContext : JsonSerializerContext;
Then create an instance with your desired options:
var ctx = new AppJsonContext(new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
Pass ctx.Options to DocumentStoreOptions.JsonSerializerOptions so that the expression visitor and serializer share the same configuration.
All JsonTypeInfo<T> parameters across the entire API are optional (= null default). When omitted, type info is automatically resolved from the configured JsonSerializerOptions.TypeInfoResolver. This means you can configure a JsonSerializerContext once and skip passing JsonTypeInfo<T> on every call — while retaining full AOT safety.
var ctx = new AppJsonContext(new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
var store = new DocumentStore(new DocumentStoreOptions
{
DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db"),
JsonSerializerOptions = ctx.Options,
UseReflectionFallback = false // recommended for AOT
});
If your types are spread across multiple JsonSerializerContext classes, use TypeInfoResolverChain to combine them. The chain is tried in order — the first context that knows about the requested type wins.
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
options.TypeInfoResolverChain.Add(UserJsonContext.Default);
options.TypeInfoResolverChain.Add(OrderJsonContext.Default);
var store = new DocumentStore(new DocumentStoreOptions
{
DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db"),
JsonSerializerOptions = options,
UseReflectionFallback = false
});
With explicit JsonTypeInfo<T> | With auto-resolution (recommended) |
|---|---|
store.Insert(user, ctx.User) | store.Insert(user) |
store.Get("id", ctx.User) | store.Get<User>("id") |
store.Upsert(patch, ctx.User) | store.Upsert(patch) |
store.SetProperty("id", (User u) => u.Age, 31, ctx.User) | store.SetProperty<User>("id", u => u.Age, 31) |
store.RemoveProperty("id", (User u) => u.Email, ctx.User) | store.RemoveProperty<User>("id", u => u.Email) |
Note:
Get,Remove,SetProperty, andRemovePropertyaccept the Id asobject— you can pass aGuid,int,long, orstringdirectly. Passing an unsupported type throwsArgumentException. |store.Query(ctx.User)|store.Query<User>()| |store.Query<User>("sql", ctx.User, parms)|store.Query<User>("sql", parameters: parms)| |store.QueryStream<User>("sql", ctx.User, parms)|store.QueryStream<User>("sql", parameters: parms)|
// All of these are AOT-safe when ctx.Options is configured
var user = new User { Name = "Alice", Age = 25 };
await store.Insert(user); // user.Id is auto-generated
var fetched = await store.Get<User>(user.Id);
var all = await store.Query<User>().ToList();
await store.Upsert(new User { Id = user.Id, Name = "Alice", Age = 30 });
var results = await store.Query<User>(
"json_extract(Data, '$.age') > @minAge",
parameters: new { minAge = 30 });
await foreach (var u in store.Query<User>().ToAsyncEnumerable())
Console.WriteLine(u.Name);
Each method checks JsonSerializerOptions.TryGetTypeInfo(typeof(T)) before falling back to reflection. If the resolver returns a JsonTypeInfo<T>, it is used for serialization. When UseReflectionFallback = false and no type info can be resolved, a clear InvalidOperationException is thrown.
By default (UseReflectionFallback = true), if no TypeInfoResolver is configured or the type isn't registered in the context, methods fall back to reflection-based serialization. Existing code without a JsonSerializerContext continues to work unchanged.
For AOT deployments, set UseReflectionFallback = false. Reflection-based serialization produces hard-to-diagnose errors under trimming and AOT. With this flag disabled, you get a clear InvalidOperationException at the point of use:
InvalidOperationException: No JsonTypeInfo registered for type 'MyApp.UnregisteredType'.
Register it in your JsonSerializerContext or pass a JsonTypeInfo<UnregisteredType> explicitly.
This tells you exactly which type is missing and what to do about it. Every type must either be registered in your JsonSerializerContext via [JsonSerializable(typeof(T))] or passed with an explicit JsonTypeInfo<T> parameter.
Every document type must have a public Id property of type Guid, int, long, or string. The Id is stored in both the SQLite Id column and inside the JSON blob, so query results always include it.
public class User
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public int Age { get; set; }
public string? Email { get; set; }
}
| Id CLR Type | Default Value | Auto-Gen Strategy |
|---|---|---|
Guid | Guid.Empty | Guid.NewGuid() |
string | null or "" | Guid.NewGuid().ToString("N") |
int | 0 | MAX(CAST(Id AS INTEGER)) + 1 per TypeName |
long | 0 | MAX(CAST(Id AS INTEGER)) + 1 per TypeName |
When Insert is called with a default Id, the store auto-generates one and writes it back to the object. When a non-default Id is provided, it is used as-is. If a document with the same Id already exists, Insert throws an exception.
var user = new User { Name = "Alice", Age = 25 };
await store.Insert(user);
// user.Id is now populated
await store.Insert(new User { Id = "user-1", Name = "Alice", Age = 25 });
// Throws if "user-1" already exists
BatchInsert inserts multiple documents in a single transaction with prepared command reuse for optimal performance. Returns the count inserted. If any document fails (e.g. duplicate Id), the entire batch is rolled back. Auto-generates IDs for Guid, int, and long Id types.
var users = Enumerable.Range(1, 1000).Select(i => new User
{
Id = $"user-{i}",
Name = $"User {i}",
Age = 20 + (i % 50)
});
var count = await store.BatchInsert(users); // 1000 — single transaction, prepared command reused
// Works with auto-generated IDs too
var models = Enumerable.Range(1, 500).Select(i => new GuidIdModel { Name = $"Item {i}" }).ToList();
await store.BatchInsert(models); // All Ids auto-populated
// Group writes atomically with a session (store.OpenSession()) (contiguous same-type inserts coalesce into the batch path)
await using var uow = store.OpenSession();
uow.AddRange(moreUsers).Add(singleUser);
await uow.SaveChanges(); // all committed or rolled back together
Update replaces the entire document. The document must have a non-default Id and must already exist; otherwise an exception is thrown.
await store.Update(new User { Id = "user-1", Name = "Alice", Age = 26 });
Upsert uses SQLite's json_patch() (RFC 7396 JSON Merge Patch) to deep-merge a partial patch into an existing document. If the document doesn't exist, it is inserted as-is. Unlike Update, which replaces the entire document, Upsert only overwrites the fields present in the patch. The document must have a non-default Id.
// Insert a full document
await store.Insert(new User { Id = "user-1", Name = "Alice", Age = 25, Email = "[email protected]" });
// Merge patch — only update Name and Age, preserve Email
await store.Upsert(new User { Id = "user-1", Name = "Alice", Age = 30 });
var user = await store.Get<User>("user-1");
// user.Name == "Alice", user.Age == 30, user.Email == "[email protected]" (preserved)
How it works:
json_patch(existing, patch) deep-merges the patch into the stored JSON. Objects are recursively merged; scalars and arrays are replaced.string? Email) serialize as null, which would remove the key under RFC 7396. The library strips these so that unset fields are preserved rather than deleted.Tip: For true partial updates, use nullable properties in your patch type so that unset fields are
nulland excluded from the merge. Non-nullable properties with default initializers (e.g.string Name = "") will always be included in the patch.
SetProperty updates a single scalar field in-place using SQLite's json_set() — no deserialization, no full document replacement. Returns true if the document was found and updated, false if not found.
// Update a scalar field
await store.SetProperty<User>("user-1", u => u.Age, 31);
// Update a string field
await store.SetProperty<User>("user-1", u => u.Email, "[email protected]");
// Set a field to null
await store.SetProperty<User>("user-1", u => u.Email, null);
// Nested property — update a city within a shipping address
await store.SetProperty<Order>("order-1", o => o.ShippingAddress.City, "Portland");
// Check if the document existed
bool updated = await store.SetProperty<User>("user-1", u => u.Age, 31);
if (!updated)
Console.WriteLine("Document not found");
How it works: The expression u => u.Age is resolved to the JSON path $.age (respecting [JsonPropertyName] attributes and naming policies). The SQL executed is:
UPDATE documents
SET Data = json_set(Data, '$.age', json('31')), UpdatedAt = @now
WHERE Id = @id AND TypeName = @typeName;
Supported value types: SetProperty is designed for scalar values — string, int, long, double, float, decimal, bool, and null. It does not support setting collection or complex object values. To replace a nested object or array, use Update (full replacement) or Upsert (merge patch).
RemoveProperty strips a field from the stored JSON using SQLite's json_remove(). Returns true if the document was found and updated, false if not found. When the document is later deserialized, the removed field will have its C# default value.
// Remove a nullable field
await store.RemoveProperty<User>("user-1", u => u.Email);
// Remove a nested property
await store.RemoveProperty<Order>("order-1", o => o.ShippingAddress.City);
// Remove a collection property (removes the entire array from the JSON)
await store.RemoveProperty<Order>("order-1", o => o.Tags);
// Check if the document existed
bool updated = await store.RemoveProperty<User>("user-1", u => u.Email);
How it works: The SQL executed is:
UPDATE documents
SET Data = json_remove(Data, '$.email'), UpdatedAt = @now
WHERE Id = @id AND TypeName = @typeName;
Unlike SetProperty, RemoveProperty works on any property type — scalar, nested object, or collection — because it simply removes the key from the JSON regardless of the value's shape.
| Operation | Use when | Scope | Collections |
|---|---|---|---|
SetProperty | Changing one scalar field | Single field, in-place json_set | Scalar values only |
RemoveProperty | Stripping a field from the document | Single field, in-place json_remove | Works on any property type |
Upsert | Patching multiple fields at once | Deep merge via json_patch | Replaces arrays entirely (RFC 7396) |
Insert | Adding a new document | Full document write; throws if Id exists | Full control |
Update | Replacing an existing document | Full replacement; throws if not found | Full control |
GetDiff | Diffing local changes vs stored state | Read-only; returns RFC 6902 patch | Deep nested diff; arrays replaced as whole |
The id parameter accepts Guid, int, long, or string. Passing an unsupported type throws ArgumentException.
var user = await store.Get<User>("user-1");
// Guid, int, and long Ids work directly — no ToString() needed
var item = await store.Get<GuidIdModel>(myGuid);
var order = await store.Get<IntIdModel>(42);
Compare a modified object against the stored document and get an RFC 6902 JsonPatchDocument<T> describing the differences. Returns null if no document with that ID exists.
Requires the SystemTextJsonPatch package (included as a dependency).
// Fetch the stored order, propose changes
var proposed = new Order
{
Id = "ord-1", CustomerName = "Alice", Status = "Delivered",
ShippingAddress = new() { City = "Seattle", State = "WA" },
Lines = [new() { ProductName = "Widget", Quantity = 10, UnitPrice = 8.99m }],
Tags = ["priority", "expedited"]
};
// Get a patch describing what changed
var patch = await store.GetDiff("ord-1", proposed);
// patch.Operations contains:
// Replace /status → Delivered
// Replace /shippingAddress/city → Seattle
// Replace /shippingAddress/state → WA
// Replace /lines → [...]
// Replace /tags → [...]
// Apply the patch to any instance of the same type
var current = await store.Get<Order>("ord-1");
patch!.ApplyTo(current!);
The diff is deep — nested objects produce individual property-level operations (e.g. /shippingAddress/city), while arrays and collections are replaced as a whole.
bool deleted = await store.Remove<User>("user-1");
bool removed = await store.Remove<GuidIdModel>(myGuid);
int deletedCount = await store.Clear<User>();
Map a version property on your document type for automatic optimistic concurrency checks. The version is stored inside the JSON blob — no schema or table changes required. Works across all providers (SQLite, LiteDB, CosmosDB, IndexedDB, MySQL, SQL Server, PostgreSQL, Oracle).
// Expression-based (reflection)
options.ConfigureDocument<Order>(cfg => cfg.MapVersionProperty(o => o.RowVersion));
// AOT-safe overload with explicit getter/setter
options.ConfigureDocument<Order>(cfg =>
cfg.MapVersionProperty("RowVersion", o => o.RowVersion, (o, v) => o.RowVersion = v));
cfg.MapVersionProperty works on every provider — it is a member of the shared ConfigureDocument builder, not of any one options class.
| Operation | Behavior |
|---|---|
Insert | Version is set to 1 before serialization |
Update | Reads the expected version from the object, checks it against the stored version, then increments. Throws ConcurrencyException on mismatch |
Upsert | Insert path sets version to 1. Update path checks and increments (only when the existing version > 0) |
BatchInsert | Version is set to 1 for each document |
public class Order
{
public string Id { get; set; } = "";
public string Status { get; set; } = "";
public int RowVersion { get; set; }
}
// Insert — RowVersion is set to 1
var order = new Order { Id = "ord-1", Status = "Pending" };
await store.Insert(order);
// order.RowVersion == 1
// Update — RowVersion is checked and incremented
order.Status = "Shipped";
await store.Update(order);
// order.RowVersion == 2
// Concurrent update — throws ConcurrencyException
var staleOrder = new Order { Id = "ord-1", Status = "Cancelled", RowVersion = 1 };
await store.Update(staleOrder); // throws ConcurrencyException
Thrown when a version mismatch is detected. Provides diagnostic properties:
| Property | Type | Description |
|---|---|---|
TypeName | string | The document type name |
DocumentId | string | The document Id |
ExpectedVersion | int | The version the caller expected |
ActualVersion | int? | The version found in the store (when available) |
try
{
await store.Update(staleOrder);
}
catch (ConcurrencyException ex)
{
Console.WriteLine($"Conflict on {ex.TypeName} {ex.DocumentId}: expected v{ex.ExpectedVersion}, found v{ex.ActualVersion}");
}
The fluent query builder is the primary way to query, filter, sort, paginate, project, aggregate, stream, and delete documents. Start with store.Query<T>() and chain builder methods, then terminate with a materialization method.
| Method | Description |
|---|---|
.Where(predicate) | Filter by LINQ expression. Multiple calls combine with AND. |
.Where(filter[, jsonTypeInfo]) | Filter by a runtime filter string (e.g. "Age >= 30 and Status == 'open'") — AOT-safe. Supports and/or/not, comparisons, is [not] null, in (…), contains/startsWith/endsWith. jsonTypeInfo is optional — reused from the query when omitted. |
.Where($"…"[, jsonTypeInfo]) | Interpolated filter — each {value} hole is captured as a typed argument and bound as a parameter (no quoting, injection-safe; the Dapper/InterpolatedSql pattern), e.g. Where($"Age >= {min} and Status == {status}"). Same grammar as Where(string); an interpolated literal binds here, a plain string binds to the parsed overload. |
.OrderBy(selector) / .OrderByDescending(selector) | Sort by property (expression). |
.OrderBy(name[, jsonTypeInfo]) / .OrderByDescending(name[, jsonTypeInfo]) | Sort by property name (string) — AOT-safe. Case-insensitive CLR or JSON name; supports dotted paths. jsonTypeInfo is optional — reused from the query when omitted. |
.OrderBy(name, direction[, jsonTypeInfo]) | Sort by property name with a runtime direction string ("asc"/"ascending"/"desc"/"descending", case-insensitive; empty defaults to ascending). jsonTypeInfo optional. |
.GroupBy(selector) | Group by property (for aggregate projections with Sql.* markers). |
.Paginate(offset, take) | Limit results with SQL LIMIT/OFFSET. |
.Select(selector, resultTypeInfo?) | Project into a different shape via json_object. |
.Project(fields[, jsonTypeInfo]) | Project a runtime-chosen field list (e.g. "name,email") into IDocumentQuery<JsonObject> — AOT-safe. Ideal for REST sparse fieldsets; no DTO required. optional. |
| Method | Returns | Description |
|---|---|---|
.ToList() | Task<IReadOnlyList<T>> | Materialize all results into a list. |
.ToAsyncEnumerable() | IAsyncEnumerable<T> | Stream results one-at-a-time without buffering. |
.Count() | Task<long> | Count matching documents. |
.Any() | Task<bool> | Check if any documents match. |
.ExecuteDelete() | Task<int> | Delete matching documents and return count deleted. |
.ExecuteUpdate(property, value) | Task<int> | Update a property on all matching documents via json_set() and return count updated. |
.Max(selector) | Task<TValue> | Maximum value of a property. |
.Min(selector) | Task<TValue> | Minimum value of a property. |
.Sum(selector) | Task<TValue> | Sum of a property. |
.Average(selector) | Task<double> | Average of a property. |
.PageResult(page, pageSize, zeroBased?) | Task<PagedResults<T>> | Run the query and return records + total count in one call. 1-based by default. |
var users = await store.Query<User>().ToList();
The preferred way to query. Property names are resolved from JsonTypeInfo metadata, so [JsonPropertyName] attributes and naming policies are respected automatically.
var results = await store.Query<User>().Where(u => u.Name == "Alice").ToList();
var older = await store.Query<User>().Where(u => u.Age > 30).ToList();
var young = await store.Query<User>().Where(u => u.Age <= 25).ToList();
var results = await store.Query<User>().Where(u => u.Age == 25 && u.Name == "Alice").ToList();
var results = await store.Query<User>().Where(u => u.Name == "Alice" || u.Name == "Bob").ToList();
var results = await store.Query<User>().Where(u => !(u.Name == "Alice")).ToList();
var noEmail = await store.Query<User>().Where(u => u.Email == null).ToList();
var hasEmail = await store.Query<User>().Where(u => u.Email != null).ToList();
var results = await store.Query<User>().Where(u => u.Name.Contains("li")).ToList();
var results = await store.Query<User>().Where(u => u.Name.StartsWith("Al")).ToList();
var results = await store.Query<User>().Where(u => u.Name.EndsWith("ob")).ToList();
var results = await store.Query<Order>().Where(o => o.ShippingAddress.City == "Portland").ToList();
// Object collection — filter by child property
var results = await store.Query<Order>()
.Where(o => o.Lines.Any(l => l.ProductName == "Widget"))
.ToList();
// Primitive collection — filter by value
var results = await store.Query<Order>()
.Where(o => o.Tags.Any(t => t == "priority"))
.ToList();
// Check if a collection has any elements
var results = await store.Query<Order>().Where(o => o.Tags.Any()).ToList();
// Count elements (no predicate)
var results = await store.Query<Order>().Where(o => o.Lines.Count() > 1).ToList();
// Count matching elements (with predicate)
var results = await store.Query<Order>()
.Where(o => o.Lines.Count(l => l.Quantity >= 3) >= 1)
.ToList();
// Property form — collection .Count and array .Length translate to the
// same array-length function as .Count(), so use whichever reads cleaner
var empty = await store.Query<Order>().Where(o => o.Lines.Count == 0).ToList();
var multi = await store.Query<Order>().Where(o => o.Tags.Count > 1).ToList();
DateTime and DateTimeOffset values are formatted to match System.Text.Json's default ISO 8601 output, so comparisons work correctly with stored JSON.
var cutoff = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var upcoming = await store.Query<Event>().Where(e => e.StartDate > cutoff).ToList();
var start = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
var end = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
var inRange = await store.Query<Event>()
.Where(e => e.CreatedAt >= start && e.CreatedAt < end)
.ToList();
var targetName = "Alice";
var results = await store.Query<User>().Where(u => u.Name == targetName).ToList();
var count = await store.Query<User>().Where(u => u.Age == 25).Count();
// With collection predicates
var count = await store.Query<Order>()
.Where(o => o.Lines.Any(l => l.ProductName == "Gadget"))
.Count();
var count = await store.Query<Order>().Where(o => o.Lines.Count() > 1).Count();
Delete documents matching a predicate in a single SQL DELETE — no need to query first.
// Simple predicate — returns number of deleted rows
int deleted = await store.Query<User>().Where(u => u.Age < 18).ExecuteDelete();
// Complex predicates with && and ||
int deleted = await store.Query<Order>()
.Where(o => o.ShippingAddress.City == "Portland" || o.Status == "Cancelled")
.ExecuteDelete();
// Nested properties
int deleted = await store.Query<Order>()
.Where(o => o.ShippingAddress.State == "OR")
.ExecuteDelete();
// Captured variables
var cutoffAge = 65;
int deleted = await store.Query<User>().Where(u => u.Age > cutoffAge).ExecuteDelete();
Update a single property on all matching documents in a single SQL UPDATE via json_set() — no deserialization needed.
// Update a scalar property on filtered docs
int updated = await store.Query<User>()
.Where(u => u.Age < 18)
.ExecuteUpdate(u => u.Age, 18);
// Update a nested property
int updated = await store.Query<Order>()
.Where(o => o.ShippingAddress.City == "Portland")
.ExecuteUpdate(o => o.ShippingAddress.City, "Eugene");
// Set a property to null
int updated = await store.Query<User>()
.Where(u => u.Name == "Alice")
.ExecuteUpdate(u => u.Email, null);
// Update all documents of a type (no Where)
int updated = await store.Query<User>().ExecuteUpdate(u => u.Age, 0);
Sort results at the SQL level using the fluent .OrderBy() and .OrderByDescending() methods.
// Ascending
var users = await store.Query<User>().OrderBy(u => u.Age).ToList();
// Descending
var users = await store.Query<User>().OrderByDescending(u => u.Age).ToList();
// With filter
var results = await store.Query<User>()
.Where(u => u.Age > 25)
.OrderBy(u => u.Name)
.ToList();
// With streaming
await foreach (var user in store.Query<User>().OrderByDescending(u => u.Age).ToAsyncEnumerable())
{
Console.WriteLine(user.Name);
}
Generated SQL: ORDER BY json_extract(Data, '$.age') ASC
When the sort column is selected at runtime (sortable table headers, REST ?sort= query strings, etc.), use the string-based overloads. They resolve the property through JsonTypeInfo<T> — no Type.GetProperty(string) reflection on T, so they stay AOT/trim-safe.
The
jsonTypeInfoargument is optional on all the string overloads (Where/OrderBy/Project). When omitted, the query reuses theJsonTypeInfo<T>it resolved when it was created (fromQuery(ctx.User)or the registeredJsonSerializerContext), so you rarely need to pass it. The examples below pass it explicitly for clarity, butstore.Query(ctx.User).OrderBy("Name")works the same.
// jsonTypeInfo omitted — reused from the query's context
var byName = await store.Query<User>(ctx.User).OrderBy("Name").ToList();
// Sort by CLR property name (explicit JsonTypeInfo)
var byName = await store.Query<User>().OrderBy("Name", ctx.User).ToList();
// Or by JSON name (after the configured naming policy)
var byName = await store.Query<User>().OrderBy("name", ctx.User).ToList();
// Descending
var oldest = await store.Query<User>().OrderByDescending("Age", ctx.User).ToList();
// Dotted path for nested properties
var orders = await store.Query<Order>().OrderBy("ShippingAddress.City", ctx.Order).ToList();
// Driven by external input
var results = await store.Query<User>()
.Where(u => u.Active)
.OrderBy(request.Sort ?? "Name", ctx.User)
.ToList();
// Direction as a runtime string too — handy for `?sort=name&dir=desc`.
// Accepts "asc"/"ascending"/"desc"/"descending" (case-insensitive);
// an empty/null/whitespace direction defaults to ascending.
var page = await store.Query<User>()
.OrderBy(request.Sort ?? "Name", request.Dir, ctx.User)
.ToList();
Matching is case-insensitive against either the CLR property name or the JSON property name. Each nested type in a dotted path must also be registered in your JsonSerializerContext. Unknown property names throw ArgumentException.
WhereIn / WhereNotInPass an in-memory collection and filter to documents whose property is (or isn't) one of its values — the IN / NOT IN pattern. The collection is lowered to each store's native construct (relational IN (…), Cosmos IN, Mongo $in, LiteDB/IndexedDB in-memory) rather than being expanded into the filter text, so one call works the same everywhere.
var statuses = new[] { "Open", "Pending", "Review" };
var open = await store.Query<Order>()
.WhereIn(o => o.Status, statuses)
.ToList();
var rest = await store.Query<Order>()
.WhereNotIn(o => o.Status, statuses)
.ToList();
null handling is explicit via the NullHandling argument (default Ignore):
Ignore — strip nulls from the set (the safe default; removes the classic NOT IN (…, NULL) "no rows" trap).Match — a null in the set is explicit intent about null fields: WhereIn also matches rows whose field is null; WhereNotIn also excludes them.Raw — pass the set through untouched and inherit the store's native three-valued logic.// "alice's rows, plus rows with no assignee"
var mine = await store.Query<Order>()
.WhereIn(o => o.AssignedTo, new string?[] { "alice", null }, NullHandling.Match)
.ToList();
An empty set is well-defined: WhereIn matches nothing, WhereNotIn matches everything. A string property-name overload (WhereIn("Status", values)) mirrors the string OrderBy/Where helpers. The same lowering powers the string filter's field in (…) form below.
When the filter itself is supplied at runtime (a REST ?filter= parameter, a saved view, an admin search box), Where(string, JsonTypeInfo<T>) parses a small expression language into the same expression tree a compiled predicate would produce — so it runs through the existing translator and stays AOT/trim-safe (it never calls Compile() and resolves fields through JsonTypeInfo).
var open = await store.Query<User>()
.Where("Age >= 30 and Status == 'open'", ctx.User)
.ToList();
// Combines with compiled predicates
var results = await store.Query<User>()
.Where(u => u.Active)
.Where(request.Filter, ctx.User)
.ToList();
Supported syntax:
and, or, not, and parentheses.== (or =), != (or <>), >, >=, <, <=. Relational operators are rejected for string/bool/Guid fields.field is null / field is not null (and field == null / field != null).field in (a, b, c).contains(field, 'x'), startsWith(field, 'x'), endsWith(field, 'x').Field names follow the same rules as the string OrderBy (case-insensitive CLR or JSON name, dotted paths). String literals use single or double quotes; double the quote to escape ('O''Brien'). Literals are coerced to each field's CLR type. Syntax errors and unknown fields throw ArgumentException.
Project(fields, JsonTypeInfo<T>) selects a runtime-chosen set of fields and returns IDocumentQuery<JsonObject> — no DTO needed. This is the natural fit for REST sparse fieldsets (?fields=name,email) that are serialized straight back to JSON.
IReadOnlyList<JsonObject> rows = await store.Query<User>()
.Where("Age >= 30", ctx.User)
.OrderBy("Name", ctx.User)
.Project("Name, Email", ctx.User)
.ToList();
var name = rows[0]["name"]!.GetValue<string>();
// Pagination, Count, Any and streaming all work on the projected query.
var page = await store.Query<User>().Project("name,email", ctx.User).PageResult(1, 20);
It emits a json_object('name', json_extract(Data,'$.name'), …) projection from the resolved JSON paths. Each output key is the leaf JSON name (so ShippingAddress.City → city); selecting two fields that resolve to the same leaf name throws. After Project, the query is terminal-shaped — ToList/ToAsyncEnumerable/Count/Any/Paginate are supported; further Where/OrderBy/Select/aggregates throw. Project is supported on the SQL providers; other providers throw NotSupportedException.
Paginate(offset, take) appends LIMIT {take} OFFSET {offset} to the generated SQL. It is a builder method that does not execute the query — it stores state until a terminal method is called.
// First page (items 0-19)
var page1 = await store.Query<User>()
.OrderBy(u => u.Name)
.Paginate(0, 20)
.ToList();
// Second page (items 20-39)
var page2 = await store.Query<User>()
.OrderBy(u => u.Name)
.Paginate(20, 20)
.ToList();
// With filtering
var page = await store.Query<User>()
.Where(u => u.Age >= 18)
.OrderBy(u => u.Age)
.Paginate(0, 10)
.ToList();
// With projection
var page = await store.Query<User>()
.OrderBy(u => u.Name)
.Paginate(0, 10)
.Select(u => new UserSummary { Name = u.Name, Email = u.Email })
.ToList();
// With streaming
await foreach (var user in store.Query<User>()
.OrderBy(u => u.Name)
.Paginate(0, 50)
.ToAsyncEnumerable())
{
Console.WriteLine(user.Name);
}
PageResult — records + total in one callFor UI/REST responses you usually want both the page slice and the total matching count. PageResult is a terminal extension that runs the count and the page query and returns a PagedResults<T> envelope.
public record PagedResults<T>(
IEnumerable<T> Records,
int TotalCount,
int Page,
int PageSize
);
// 1-based by default — page 1 is the first page
var result = await store.Query<User>()
.Where(u => u.Active)
.OrderBy(u => u.Name)
.PageResult(page: 1, pageSize: 20);
return new {
items = result.Records,
total = result.TotalCount,
page = result.Page,
pageSize = result.PageSize
};
// Zero-based opt-in (page 0 is the first page)
var result = await store.Query<User>()
.OrderBy(u => u.Name)
.PageResult(page: 0, pageSize: 20, zeroBased: true);
TotalCount reflects the current Where predicates (and global query filters) — pagination state is ignored when counting, so the total spans every page, not just the returned slice..Paginate(...) call on the query is overridden.pageSize must be greater than zero. page must be >= 1 (or >= 0 when zeroBased: true). Otherwise throws ArgumentOutOfRangeException.Project query results into a different shape using .Select(). Only the selected properties are extracted at the SQL level via json_object — no full document deserialization needed.
var results = await store.Query<User>()
.Where(u => u.Age == 25)
.Select(u => new UserSummary { Name = u.Name, Email = u.Email })
.ToList();
var results = await store.Query<Order>()
.Where(o => o.Status == "Shipped")
.Select(o => new OrderSummary { Customer = o.CustomerName, City = o.ShippingAddress.City })
.ToList();
var results = await store.Query<Order>()
.Select(o => new OrderDetail { Customer = o.CustomerName, LineCount = o.Lines.Count() })
.ToList();
Use Count(), Count(predicate), Any(), and Any(predicate) inside projection selectors:
// Count() — total number of elements
o => new OrderDetail { Customer = o.CustomerName, LineCount = o.Lines.Count() }
// SQL: json_array_length(Data, '$.lines')
// Count(predicate) — filtered count
o => new OrderDetail { Customer = o.CustomerName, GadgetCount = o.Lines.Count(l => l.ProductName == "Gadget") }
// SQL: (SELECT COUNT(*) FROM json_each(Data, '$.lines') WHERE json_extract(value, '$.productName') = @pp0)
// Any() — has any elements
o => new OrderDetail { Customer = o.CustomerName, HasLines = o.Lines.Any() }
// SQL: CASE WHEN json_array_length(Data, '$.lines') > 0 THEN json('true') ELSE json('false') END
// Any(predicate) — any element matches
o => new OrderDetail { Customer = o.CustomerName, HasPriority = o.Tags.Any(t => t == "priority") }
// SQL: CASE WHEN EXISTS (SELECT 1 FROM json_each(Data, '$.tags') WHERE value = @pp0) THEN json('true') ELSE json('false') END
Inner predicates support the same operators as WHERE clause expressions: comparisons, logical operators, null checks, string methods (Contains, StartsWith, EndsWith), and captured variables.
Compute Max, Min, Sum, Average across documents using terminal methods on the query builder.
var maxAge = await store.Query<User>().Max(u => u.Age);
var minAge = await store.Query<User>().Min(u => u.Age);
var totalAge = await store.Query<User>().Sum(u => u.Age);
var avgAge = await store.Query<User>().Average(u => u.Age);
// With predicate filter
var maxAge = await store.Query<User>().Where(u => u.Age < 35).Max(u => u.Age);
Use Sql marker class for aggregate projections with automatic GROUP BY.
var results = await store.Query<Order>()
.Select(o => new OrderStats
{
Status = o.Status, // GROUP BY column
OrderCount = Sql.Count(), // COUNT(*)
})
.ToList();
// All Sql markers: Sql.Count(), Sql.Max(x.Prop), Sql.Min(x.Prop), Sql.Sum(x.Prop), Sql.Avg(x.Prop)
// With predicate filter
var results = await store.Query<Order>()
.Where(o => o.Status == "Shipped")
.Select(o => new OrderStats { Status = o.Status, OrderCount = Sql.Count() })
.ToList();
// Explicit GroupBy
var results = await store.Query<Order>()
.GroupBy(o => o.Status)
.Select(o => new OrderStats { Status = o.Status, OrderCount = Sql.Count() })
.ToList();
Use .ToAsyncEnumerable() instead of .ToList() to stream results one-at-a-time without buffering the entire result set into memory.
// Stream all
await foreach (var user in store.Query<User>().ToAsyncEnumerable())
{
Console.WriteLine(user.Name);
}
// Stream with filter and sort
await foreach (var user in store.Query<User>()
.Where(u => u.Age > 30)
.OrderBy(u => u.Name)
.ToAsyncEnumerable())
{
Console.WriteLine(user.Name);
}
// Stream with projection
await foreach (var summary in store.Query<Order>()
.Where(o => o.Status == "Shipped")
.Select(o => new OrderSummary { Customer = o.CustomerName, City = o.ShippingAddress.City })
.ToAsyncEnumerable())
{
Console.WriteLine($"{summary.Customer} in {summary.City}");
}
// Stream with pagination
await foreach (var user in store.Query<User>()
.OrderBy(u => u.Name)
.Paginate(0, 50)
.ToAsyncEnumerable())
{
Console.WriteLine(user.Name);
}
Note: On shared-connection providers (SQLite, SQLCipher, DuckDB) streaming holds the per-store semaphore for the duration of enumeration — do not call other store methods inside the same
await foreach, they will block until it completes. On pooled providers (PostgreSQL, MySQL, SQL Server, Oracle) the streaming reader holds one connection out of the driver pool and does not block other callers, but interleaving writes can still produce surprising results for consumers expecting a stable snapshot.
For advanced queries not covered by expressions, use raw SQL with provider-specific JSON functions. The SQL syntax varies by provider:
| Provider | JSON extract syntax |
|---|---|
| SQLite | json_extract(Data, '$.name') |
| MySQL | JSON_EXTRACT(Data, '$.name') |
| SQL Server | JSON_VALUE(Data, '$.name') |
| PostgreSQL | "Data"::jsonb->>'name' |
| Oracle | JSON_VALUE(Data, '$.name') |
// SQLite example
var results = await store.Query<User>(
"json_extract(Data, '$.name') = @name",
parameters: new { name = "Alice" });
// With dictionary parameters (AOT-safe)
var parms = new Dictionary<string, object?> { ["name"] = "Alice" };
var results = await store.Query<User>(
"json_extract(Data, '$.name') = @name",
parameters: parms);
// Count with raw SQL
var count = await store.Count<User>(
"json_extract(Data, '$.age') > @minAge",
new { minAge = 30 });
// Streaming with raw SQL
await foreach (var user in store.QueryStream<User>(
"json_extract(Data, '$.name') = @name",
parameters: new { name = "Alice" }))
{
Console.WriteLine(user.Name);
}
The fluent query builder is composable — each .Where() call returns a new builder, so you can conditionally chain filters, sorting, and pagination at runtime:
// Search parameters (from user input, API request, etc.)
string? nameFilter = "A";
int? minAge = null;
bool? isActive = true;
string sortBy = "name";
int page = 0, pageSize = 10;
var query = store.Query<User>();
if (!string.IsNullOrEmpty(nameFilter))
query = query.Where(u => u.Name.StartsWith(nameFilter));
if (minAge.HasValue)
query = query.Where(u => u.Age >= minAge.Value);
if (isActive.HasValue)
query = query.Where(u => u.IsActive == isActive.Value);
query = sortBy switch
{
"name" => query.OrderBy(u => u.Name),
"age" => query.OrderByDescending(u => u.Age),
_ => query
};
var results = await query.Paginate(page * pageSize, pageSize).ToList();
var totalCount = await query.Count(); // same filters, no pagination
Multiple .Where() calls are AND'd together in the generated SQL.
Grouping writes into one transaction is done through an IDocumentSession opened from the store (await using var session = store.OpenSession();) — there is no RunInTransaction. Queue Add/AddRange/Update/Upsert/Remove, then SaveChanges (commits on success, rolls back on exception). Contiguous same-type inserts coalesce into the batch-insert fast path. A session is a write buffer, not a change tracker — reads don't see operations buffered before SaveChanges; for read-modify-write atomicity use ETag/CAS + retry, or an explicit await using var tx = await session.BeginTransaction(); with a LockMode.Update read (relational providers). Inject IDocumentSession (scoped, ASP.NET — AddScopedDocumentSession()) or IDocumentSessionFactory (no ambient scope — MAUI/desktop/background).
await using var uow = store.OpenSession();
uow.Add(new User { Id = "u1", Name = "Alice", Age = 25 })
.Add(new User { Id = "u2", Name = "Bob", Age = 30 });
await uow.SaveChanges();
Register interceptors to observe/mutate writes; the after-hook runs inside the transaction with the generated id/version (e.g. for a transactional outbox). Per-document (IDocumentInterceptor) fires for Insert/BatchInsert(per item)/Update/Upsert/Remove; bulk (IDocumentBulkInterceptor) fires once for ExecuteUpdate/ExecuteDelete/Clear. Supported across every provider.
opts.AddInterceptor(new AuditInterceptor());
opts.ConfigureDocument<Order>(cfg =>
{
cfg.OnBeforeWrite((ctx, ct) => { /* mutate ctx.Document or throw to abort */ return Task.CompletedTask; });
cfg.OnAfterWrite((ctx, ct) => outbox.Enqueue(ctx.Id, ctx.Operation, ct));
});
Interceptors can also be registered in DI to get constructor-injected dependencies — AddDocumentStore resolves every IDocumentInterceptor/IDocumentBulkInterceptor from the container and runs them after the options-registered ones. Register them as singletons. DI interceptors fire across every provider (including the non-relational ones and Orleans grain storage).
services.AddSingleton<IDocumentInterceptor, OutboxInterceptor>(); // ctor deps injected
services.AddDocumentStore(opts => opts.DatabaseProvider = new SqliteDatabaseProvider("Data Source=app.db"));
Scoped services + a transaction-bound session in a hook — an interceptor resolves request-scoped services from ctx.Services (no marker interface — any DI-registered interceptor gets a scope, scoped registrations included; ctx.Services is never null and is resolved fresh from the flowing scope, so through a scoped IDocumentSession/DocumentContext it's the caller's own request scope, otherwise a fresh child scope per unit). A single write with per-doc interceptors runs as an implicit one-op unit of work, so ctx.Session (and ctx.Store) are bound to that transaction: read this unit's uncommitted rows and write side effects (an outbox row) that commit atomically with the triggering write, with no shared-connection deadlock. int Order sequences interceptors deterministically. Full read-your-writes visibility is relational + LiteDB; other backends are committed-state.
public sealed class OrderInterceptor : IDocumentInterceptor // no marker; may be registered scoped too
{
public async Task BeforeWrite(DocumentWriteContext ctx, CancellationToken ct)
=> await ctx.Services.GetRequiredService<IOrderValidator>().Validate((Order)ctx.Document!, ct);
public async Task AfterWrite(DocumentWriteContext ctx, CancellationToken ct)
{
using (ctx.Session.SuppressInterceptors()) // atomic outbox, no re-entry
{
ctx.Session.Add(new OutboxEntry(ctx.Id!, "OrderPlaced")); // flushes into THIS transaction
await ctx.Session.SaveChanges(ct);
}
}
}
Replacing a write instead of observing it (ctx.Cancel()) — in BeforeWrite, ctx.Cancel(bool succeeded = true) tells the store to issue no write for that operation because the interceptor performed one itself: no AfterWrite, no change notification or temporal entry, and later interceptors are skipped. Remove returns the succeeded value. Set-based writes use DocumentBulkContext.Cancel(int affected = 0), with ctx.QueryAs<T>() handing back the originating query (same predicate and filters; null for Clear, where ctx.Store is the handle) so it can be re-issued differently. Do the replacement write through ctx.Store/ctx.Session so it commits with the same unit. Throwing still aborts the write outright — cancelling is not an error path. This is what makes an interceptor able to change the shape of an operation (delete → update), and it is exactly how the built-in soft delete below is implemented.
opts.ConfigureDocument<Order>(cfg => cfg.OnBeforeWrite(async (ctx, ct) =>
{
if (ctx.Operation != DocumentOperation.Delete) return;
var updated = await ctx.Store.SetProperty<Order>(ctx.Id!, x => x.Status, "voided", null, ct);
ctx.Cancel(updated); // no DELETE is issued; Remove() returns `updated`
}));
The nine document providers share one IDocumentQuery<T> implementation. Derive from the public DocumentQueryBase<T> and implement four members — Clone(), ExecuteAsync(QueryPlan<T>), DeleteMatchingAsync, SetPropertyMatchingAsync (optionally overriding SetPropertiesMatchingAsync when the engine can set several JSON paths at once) — and you inherit builder state and immutability, query-filter resolution, every client-side terminal, grouping, cursor pagination, string projection, and the set-based-write interceptor plumbing. ExecuteAsync reports how much of the plan the engine satisfied (Candidates / Filtered / Complete / Partial) and the base applies only the remainder, so push-down is preserved rather than assumed. Implement IDocumentStoreOptions on your options class (interceptors, query filters, and the serializer-options accessor) and cross-cutting features — soft delete, JSON Schema validation, field-level encryption — work on your provider with no further code. Store writes get the same treatment: DocumentProviderBase owns the write preamble (BeginWriteAsync), the id policy (ResolveInsertId / RequireDocumentId), and the tail (CompleteWriteAsync — after-hooks plus the unit-of-work-buffered change notification), so a provider writes only its persistence and its own conflict semantics.
cfg.AddSoftDelete(x => x.IsDeleted) maps a soft-delete flag: Remove, query.ExecuteDelete(), and Clear<T>() set the flag instead of deleting, and a named query filter (soft-delete) hides flagged documents from every read. The flag is a bool (set to true) or a nullable DateTime/DateTimeOffset (stamped from a DI-registered TimeProvider when present); anything else throws. It's composed from the two public building blocks — a query filter plus a cancelling interceptor — and ships as an extension method, so nothing about it is baked into the stores. Available on every provider: cfg.AddSoftDelete is an extension on the shared ConfigureDocument builder, so there is nothing per-provider about it.
opts.ConfigureDocument<Customer>(cfg => cfg.AddSoftDelete(x => x.IsDeleted));
await store.Remove<Customer>("c1"); // UPDATE … SET IsDeleted = true
await store.Get<Customer>("c1"); // null — hidden by the filter
await store.Query<Customer>().IncludeDeleted().ToList(); // reads past it
await store.Query<Customer>().OnlyDeleted().ToList(); // just the flagged ones
await store.Restore<Customer>(x => x.Id == "c1"); // clear the flag (predicate, not id)
await store.PurgeDeleted<Customer>(); // real DELETE of flagged docs (optional predicate)
await store.HardDelete<Customer>("c1"); // real DELETE of one live doc
One flag per document type. Restore takes a predicate because a flagged document is invisible to a by-id write. Insert/Upsert stay unfiltered (as with any query filter), so a document inserted with the flag already set is immediately invisible. Because a set-based delete is re-issued as an update over the same query, spatial/vector/blob sidecar rows stay with the document instead of being orphaned. Change feeds emit Updated and temporal records an update — the write really is an update.
Records a domain event in the same transaction as the write that caused it, then delivers it in the background — no second datastore, no dual-write window. Two registrations, each doing one thing: AddOutbox maps the message type onto the store, AddDocumentOutbox starts the processor.
services.AddDocumentStore(o =>
{
o.DatabaseProvider = new SqliteDatabaseProvider(path);
o.AddOutbox(); // maps OutboxMessage → its own "outbox" table
});
services.AddDocumentOutbox<BusDispatcher>(); // the background processor + IOutboxAdmin
await using var session = store.OpenSession();
session.Add(order).Enqueue(new OrderPlaced(order.Id, order.Total));
await session.SaveChanges(); // the order row and the message row commit together, or neither does
Always enqueue through the session doing the aggregate write — a store.Insert of the message in a separate call is a dual write with extra steps. Declaratively, per type, from inside the write's own transaction:
o.ConfigureDocument<Order>(cfg => cfg.PublishToOutbox(
ctx => new OrderChanged((string)ctx.Id!, ctx.Operation), // return null to publish nothing
OutboxOperations.Insert | OutboxOperations.Update,
partitionKey: ctx => (string)ctx.Id!));
Delivery is at-least-once — consumers must be idempotent. IOutboxDispatcher.Dispatch throws to retry and returns to acknowledge, and is resolved from a fresh DI scope per message. Claiming is per-message optimistic concurrency: two workers that read the same message both write it back, and the loser gets a ConcurrencyException and moves on. No leases table, no leader election, and any number of workers scale by just running. Because the claim pushes AvailableAt forward by the backoff, the backoff doubles as a visibility timeout — a processor that dies mid-dispatch releases its message rather than stranding it. OrderedPartitions orders within a PartitionKey; a poison message blocks its own partition only, and only until it dead-letters.
// Read-only observation — never claims, so it cannot compete with the processor.
await foreach (var m in store.WatchOutbox(o => o.States = OutboxStates.DeadLettered, ct))
logger.LogError("Outbox dead letter {Id}: {Error}", m.Id, m.Error);
var admin = sp.GetRequiredService<IOutboxAdmin>();
await admin.PendingCount();
await admin.OldestPendingAt(); // ALERT ON THIS, not on the depth
await admin.Requeue(ids); // dead letters only — scheduled and processed are refused
await admin.PurgeProcessed(DateTimeOffset.UtcNow.AddDays(-7));
A healthy busy system has a large pending count; a system whose processor died has an old one. Depth cannot tell those apart, age can — hence OldestPendingAt and the db.client.outbox.pending gauge beside it. WatchOutbox is a poll nudged early by change notification, not the other way round: a message becomes pending again because its AvailableAt elapsed, which is a clock event with no write behind it, and in-process notifications are blind to other writers anyway.
Telemetry comes with it: span outbox.dispatch, counters db.client.outbox.dispatched / .dead_lettered, a dispatch-duration histogram and a pending-depth gauge. The traceparent captured at enqueue is restored at dispatch, so a consumer's span links back to the request that caused the event.
Provider support. The outbox requires a store whose unit of work is a real transaction, checked once at host startup via IDocumentStore.SupportsTransactions:
| Tier | Providers |
|---|---|
| Supported | SQLite, SQLCipher, PostgreSQL, CockroachDB, SQL Server, MySQL, MariaDB, Oracle, DuckDB, LiteDB |
| Gated out | MongoDB, Cosmos DB, Redis, RavenDB, Azure Table, DynamoDB, Firestore, IndexedDB |
The gated backends implement a unit of work by compensation — a failed unit undoes its inserts, but a process that dies mid-unit runs no compensation at all, which is exactly the window an outbox exists to close. Cosmos DB is a further no: "same transaction" there means "same logical partition", and the store partitions by type name. AddDocumentOutbox throws at startup naming the provider rather than shipping a promise that does not hold; use IChangeFeedDocumentStore on those.
Messages are ordinary documents in their own table, so backup, replication and ClearAll carry them like anything else. Their wire names are pinned with [JsonPropertyName] (mirrored by OutboxFields) because other processes — the admin tool among them — read these rows. Without a generic host, OutboxRunner is the same work as a plain object: DrainOnce(), PurgeExpired(), PendingDepth().
Stores that implement IObservableDocumentStore expose an IAsyncEnumerable<DocumentChange<T>> that you can await foreach over to react to local writes. Notifications are in-process: they fire for inserts, updates, removes and clears performed through this store instance. Changes made by other processes or other store instances are not observed — for that, use the native change feed (IChangeFeedDocumentStore.SubscribeChanges<T>).
Supported on DocumentStore (SQLite, SQLCipher, MySQL, SQL Server, PostgreSQL, Oracle) and LiteDbDocumentStore. Cosmos, MongoDB, IndexedDB and DuckDB do not implement it.
using var cts = new CancellationTokenSource();
_ = Task.Run(async () =>
{
await foreach (var change in store.NotifyOnChange<User>(cts.Token))
{
Console.WriteLine($"{change.ChangeType} {change.Id} {change.Document?.Name}");
}
});
await store.Insert(new User { Id = "u1", Name = "Alice", Age = 25 });
await store.Update(new User { Id = "u1", Name = "Alice", Age = 26 });
await store.Remove<User>("u1");
cts.Cancel(); // stop the loop
WhenDocumentChanged<T>(id) filters the stream to a single document Id:
var observable = (IObservableDocumentStore)store;
await foreach (var change in observable.WhenDocumentChanged<Order>("ord-1", ct))
{
UpdateUi(change);
}
Every IDocumentQuery<T> exposes .NotifyOnChange(ct) — it filters the change stream by the query's Where predicates. OrderBy, Paginate, and GroupBy are ignored (they affect result shape, not membership).
var pending = store.Query<Order>().Where(o => o.Status == "Pending");
await foreach (var change in pending.NotifyOnChange(ct))
{
// Only fires when an Order whose Status == "Pending" is inserted or updated.
}
Caveats for property-level paths: SetProperty, RemoveProperty, Remove, and Clear do not materialize the full document, so DocumentChange<T>.Document is null for those events. The per-query filter passes them through unconditionally so your consumer can re-check membership by re-querying.
| Property | Description |
|---|---|
ChangeType | Inserted, Updated, Removed, or Cleared |
Id | The document Id (empty for Cleared) |
Document | The document body. Populated for Inserted / Updated (full-document path); null for Removed, Cleared, and property-level updates |
Changes performed in a session are buffered and emitted only after SaveChanges commits. A rollback discards the buffered events:
await using var uow = store.OpenSession();
uow.Add(new User { Id = "u1", Name = "Alice" })
.Add(new User { Id = "u2", Name = "Bob" });
// Subscribers see nothing yet.
await uow.SaveChanges();
// Subscribers receive both Inserted events here, in order.
Cancel the CancellationToken passed to NotifyOnChange (or break out of the await foreach). The subscription's channel is unregistered automatically when the iterator exits.
Register a predicate that's automatically AND-applied to every query of T — the same shape as Entity Framework Core's HasQueryFilter. Use this for soft-delete, row-level security, or any "active only" scope that should be transparent to consumer code.
var store = new DocumentStore(new DocumentStoreOptions
{
DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
}
);
options.ConfigureDocument<User>(cfg => cfg.AddQueryFilter(u => !u.IsDeleted)); // unnamed
options.ConfigureDocument<Order>(cfg =>
{
cfg.AddQueryFilter("tenant", o => o.TenantId == ctx.Current); // named
cfg.AddQueryFilter("status", o => o.Status != "Archived");
});
Filters compose with .Where(...) and with each other — every registered filter is AND'd, then the user's Where predicates are AND'd after that. Captured variables (ctx.Current) are re-read on every translation, so per-request values work without rebuilding the store.
| Path | Filtered? |
|---|---|
Query<T>() + all terminals (ToList, ToAsyncEnumerable, Count, Any, Max/Min/Sum/Average, ExecuteUpdate, ExecuteDelete) | Yes |
query.NotifyOnChange() | Yes — only changes whose document matches the filter are emitted |
Get<T>(id) / GetDiff<T>(id, ...) | Yes — returns null if the stored doc fails the filter |
Update<T> | Yes — throws "not found" if the stored doc fails the filter |
SetProperty<T> / RemoveProperty<T> | Yes — returns false if the stored doc fails the filter |
Remove<T>(id) | Yes — returns false (no-op) if the stored doc fails the filter |
Clear<T>() | Yes — only matching docs are deleted |
Count<T>(rawSql) | Yes |
Insert<T> / BatchInsert<T> | No — inserts always succeed (matches EF Core) |
Upsert<T> | No — Upsert bypasses filters; use Get + Update if you need filter enforcement |
Query<T>(rawSql) / QueryStream<T>(rawSql) | No — raw SQL is yours (matches EF Core's FromSqlRaw) |
// Disable every filter on this query
var allUsers = await store.Query<User>().IgnoreQueryFilters().ToList();
// Disable specific named filters (others still apply)
var anyTenant = await store.Query<Order>().IgnoreQueryFilters("tenant").ToList();
// Multiple names
var dump = await store.Query<Order>().IgnoreQueryFilters("tenant", "status").ToList();
IgnoreQueryFilters must be called before Select(...) — calling it on a projected query throws.
Predicates re-translate on each query, so closures pick up the current value:
var ctx = new TenantContext();
options.ConfigureDocument<Order>(cfg => cfg.AddQueryFilter("tenant", o => o.TenantId == ctx.Current));
ctx.Current = "acme";
await store.Query<Order>().ToList(); // filters by acme
ctx.Current = "globex";
await store.Query<Order>().ToList(); // re-reads, filters by globex
JsonTypeInfo<T> for SQL-providers — they're translated through the same expression visitor as Where. Configure a JsonSerializerContext on DocumentStoreOptions.JsonSerializerOptions (or pass JsonTypeInfo<T> to the call sites that take it). Without one, a registered filter throws InvalidOperationException at first use.Remove/Clear — when a row fails the filter, the main delete is skipped but the spatial path's bulk operations may treat un-matched rows differently. If you mix soft-delete with spatial indexing, prefer Update (setting the deleted flag) over Remove.Where the in-process broadcaster only sees this instance's own writes, IChangeFeedDocumentStore.SubscribeChanges<T> observes the underlying database itself — including writes from other processes, other store instances, and other connections. Backed by the database's native mechanism:
| Provider | Mechanism |
|---|---|
| PostgreSQL | LISTEN / NOTIFY with row-level triggers (true push) |
| SQL Server | Change Tracking, optionally with SqlDependency query notifications (SqlServerChangeFeedOptions) |
| Cosmos DB | Native Change Feed API |
Provisioning (triggers, enabling Change Tracking) is automatic and idempotent. SQLite, LiteDB, IndexedDB, MySQL, Oracle, and DuckDB throw NotSupportedException.
await using var sub = await store.SubscribeChanges<User>(async (change, ct) =>
{
Console.WriteLine($"{change.ChangeType} {change.Id}");
});
// Subscription runs until `sub` is disposed.
Opt a document type into append-only history with cfg.MapTemporal. Every mutation records a versioned snapshot to a per-type history sidecar, so you can read a document's state as of any point in time, audit changes, restore prior versions, and diff between versions. Opt-in per type — only mapped types pay the extra write.
options.ConfigureDocument<Order>(cfg => cfg.MapTemporal(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
}));
Tracked operations: Insert, Update, Upsert, Remove, SetProperty, RemoveProperty, and BatchInsert, including writes in a session (buffered and committed atomically). Clear<T> is a bulk delete and is not tracked.
Supported on every provider — the relational stores (SQLite, SQLCipher, PostgreSQL, SQL Server, MySQL, Oracle, DuckDB) and the document stores (LiteDB, MongoDB, CosmosDB, IndexedDB). Each persists versions to its own sidecar: a {table}_history table (relational), a {collection}_history collection (LiteDB, MongoDB), a {container}_history container (CosmosDB, partitioned by /typeName), or a {store}_history object store (IndexedDB).
IDocumentStoreHistory is an optional capability, not part of the universal CRUD contract — so it lives on its own interface, ITemporalDocumentStore : IDocumentStore, the same way observation lives on IObservableDocumentStore and the native change feed on IChangeFeedDocumentStore. Putting History/AsOf/Restore/… on IDocumentStore would force every consumer of a plain store to see seven methods that throw far more often than they work (they require the type to be cfg.MapTemporal-mapped), and force every backend to implement them. Asking for ITemporalDocumentStore instead makes "this store does history" a compile-time, discoverable fact. Resolve or cast to it:
var store = serviceProvider.GetRequiredService<ITemporalDocumentStore>();
Calling a history method for a type that wasn't passed to cfg.MapTemporal throws InvalidOperationException.
// Every version of one document (oldest first)
IReadOnlyList<DocumentVersion<Order>> history = await store.History<Order>(orderId);
// State at a point in time (null if it didn't exist / was removed then)
Order? then = await store.AsOf<Order>(orderId, lastTuesday);
// Restore a prior version as the new current state (re-inserts if it had been removed)
Order? restored = await store.Restore<Order>(orderId, version: 7);
// RFC 6902 patch between two versions (temporal analogue of GetDiff)
JsonPatchDocument<Order>? patch = await store.GetDiffBetween<Order>(orderId, 3, 7);
Backed by secondary indexes on the history table:
// Point-in-time snapshot of every live document of the type
IReadOnlyList<Order> snapshot = await store.AsOfAll<Order>(endOfQuarter);
// Per-user audit trail (requires CaptureActor)
IReadOnlyList<DocumentVersion<Order>> byAlice = await store.ChangesByActor<Order>("[email protected]");
// Audit log over a time window (ValidFrom in [from, to))
IReadOnlyList<DocumentVersion<Order>> log = await store.ChangesBetween<Order>(weekStart, weekEnd);
| Property | Description |
|---|---|
Id | The document's string Id. |
Version | Monotonic version number, starting at 1. |
ValidFrom / ValidTo | The interval this version was current. ValidTo is null for the version in effect now. |
Operation | Inserted, Updated, or Removed. |
Actor | The captured actor, when CaptureActor was configured. |
Document | The state at this version. null for Removed tombstones. |
Both prune on every write; the current version is never pruned. Set at least one on SQLite/mobile to keep the file bounded.
| Option | Behaviour |
|---|---|
Retention (TimeSpan?) | Deletes closed versions whose ValidTo is older than now - Retention. |
MaxVersions (int?) | Keeps only the newest N versions per document. |
On the relational providers the sidecar carries a (Id, TypeName, Version) primary key plus (TypeName, ValidFrom, ValidTo) and (TypeName, Actor) secondary indexes; the document stores model the same versions in their native sidecar collection/container/object store and compute the point-in-time selection in the provider. For merge/partial writes (Upsert/SetProperty/RemoveProperty) the resulting document is read back so history stores the true post-image — a cost incurred only for temporal-mapped types.
IndexedDB: because temporal adds new object stores, an existing database must be opened at a higher
Versionso the schema upgrade creates them — bumpoptions.Versionwhen addingcfg.MapTemporalto an already-deployed store. A fresh database needs no change.
The core Shiny.DocumentDb package emits OpenTelemetry-native metrics and distributed tracing for every store operation — embedded and always-on, on every provider and every construction path (DI or new …DocumentStore(options)). Built on the standard .NET primitives (System.Diagnostics.Metrics.Meter and ActivitySource), it plugs straight into OpenTelemetry, the .NET Aspire dashboard, Application Insights, or Prometheus/Grafana. It's zero-cost when nobody is listening — instruments no-op with no meter subscriber and spans aren't allocated with no ActivityListener — so there's nothing to opt into and no decorator to register.
Structured ILogger logging comes for free when a store is created from the container (any provider's Add…DocumentStore or IServiceProvider constructor) with an ILoggerFactory registered: every SQL / operation statement is logged at Debug under the Shiny.DocumentDb category (control it with Logging:LogLevel:Shiny.DocumentDb), flowing into Serilog / OTel logs / App Insights — the options.Logging string callback still fires alongside it. Works across the relational core and all six non-relational providers.
services.AddDocumentStore(o => o.DatabaseProvider = new SqliteDatabaseProvider("Data Source=app.db"));
// Nothing to register on the store — just subscribe your OTel pipeline to its meter/source:
services.AddOpenTelemetry()
.WithMetrics(m => m.AddMeter("Shiny.DocumentDb"))
.WithTracing(t => t.AddSource("Shiny.DocumentDb"));
The db.system.name tag is derived from the store's backend, so it works across every provider with no per-provider config. A store registered with the keyed overload AddDocumentStore("orders", …) automatically tags its signals with db.namespace = "orders" so multiple stores are distinguishable. (The old AddDocumentStoreInstrumentation()/InstrumentedDocumentStore/o.Instrumentation decorator API was removed in 11.0 — instrumentation is embedded now.)
Instrument and tag names follow the OpenTelemetry database client semantic conventions:
| Instrument | Kind | Meaning |
|---|---|---|
db.client.operation.duration | Histogram (s) | Per-operation latency — the primary signal. |
db.client.operations | Counter | Operation count. |
db.client.response.returned_rows | Histogram | Documents returned / affected. |
Tagged with db.system.name (sqlite, postgresql, mongodb, …), db.operation.name (insert, get, query.to_list, history, …), db.collection.name (the document type), outcome (success/error), error.type on failures, and db.namespace (the store name, on keyed/named stores only). Each operation also starts a {system}.{operation} ActivityKind.Client span carrying the same tags, with Error status + exception capture on failure.
CRUD, string + fluent-query terminals (ToList/Count/Any/ExecuteDelete/ExecuteUpdate/aggregates), spatial/vector, all ITemporalDocumentStore operations, and a session SaveChanges — where the queued operations become child spans of the transaction span. NotifyOnChange/SubscribeChanges are long-lived subscriptions and pass through untraced. Only metadata is recorded — never document bodies, ids, or parameter values.
InstrumentedDocumentStore is a faithful decorator (also surfaces ITemporalDocumentStore/IObservableDocumentStore/IChangeFeedDocumentStore); the wrapped store is reachable via its Inner property. Keyed registrations (the named AddDocumentStore(name, …) overload) are not auto-decorated — wrap those manually.
The SQLite packages deliberately stay on the SQLitePCLRaw 2.1.x bundle that Microsoft.Data.Sqlite brings, so restore reports NU1903 for SQLitePCLRaw.lib.e_sqlite3 (GHSA-2m69-gcr7-jv3q / CVE-2025-6965: SQLite before 3.50.2 can corrupt memory on a crafted aggregate query). Exploiting it means running attacker-crafted SQL against the database, so it is not a critical problem for a typical mobile app. SQLitePCLRaw 3.x has no SQLCipher bundle, and an app gets exactly one SQLitePCLRaw core, so updating SQLitePCLRaw to 3.x breaks SQLCipher for now. Apps that don't use SQLCipher can opt in themselves with <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />.
Change the encryption key of an existing SQLCipher database using the RekeyAsync extension method on IDocumentStore. This issues PRAGMA rekey under the hood. Throws InvalidOperationException if the store is not using SqlCipherDatabaseProvider.
using Shiny.DocumentDb.Sqlite.SqlCipher;
await store.RekeyAsync("newPassword");
Important: After rekeying, the store still holds the old password internally. Create a new store with the new password for subsequent operations.
Creates a hot backup of the database to a file. Only available on concrete types — not on IDocumentStore. The store remains fully usable during the backup.
SqliteDocumentStore): Uses the SQLite Online Backup APISqlCipherDocumentStore): Backup is automatically encrypted with the same passwordLiteDbDocumentStore): Requires a file-based connection string with a Filename parameter// SQLite
var sqliteStore = new SqliteDocumentStore("Data Source=mydata.db");
await sqliteStore.Backup("/path/to/backup.db");
// SQLCipher
var cipherStore = new SqlCipherDocumentStore("encrypted.db", "mySecretKey");
await cipherStore.Backup("/path/to/backup.db"); // encrypted with same password
// LiteDB
var liteStore = new LiteDbDocumentStore(new LiteDbDocumentStoreOptions { ConnectionString = "Filename=mydata.db" });
await liteStore.Backup("/path/to/backup.db");
Deletes all documents across all tables in the SQLite database, including spatial sidecar tables. Only available on SqliteDocumentStore.
var sqliteStore = new SqliteDocumentStore("Data Source=mydata.db");
await sqliteStore.ClearAllAsync();
Spatial queries are supported on SQLite (R*Tree bbox), PostgreSQL / MySQL / SQL Server / Oracle / DuckDB (envelope-sidecar bbox), all with an in-process relate/refine — plus CosmosDB (native GeoJSON ST_INTERSECTS/ST_WITHIN/ST_DISTANCE) and MongoDB (2dsphere + $geoIntersects/$geoWithin/$near). On the relational providers the sidecar also carries a native, spatially-indexed geometry column by default, which on PostgreSQL provisions PostGIS (CREATE EXTENSION) and on DuckDB loads the spatial extension — set PortableSpatial = true for the dependency-free envelope-only tier when the extension isn't available (the store throws a DocumentConfigurationException naming that remedy if provisioning fails). The fallback stores (LiteDB, IndexedDB, Azure Table, DynamoDB) throw NotSupportedException. Check support at runtime with store.SupportsSpatial.
Both point queries (WithinRadius/WithinBoundingBox/NearestNeighbors with GeoPoint) and full OGC geometry are supported. For the geometry model (GeoLineString/GeoPolygon/GeoMultiPoint/GeoMultiLineString/GeoMultiPolygon/GeoGeometryCollection), the Geo-prefixed predicate family (GeoIntersects, GeoContainedBy, GeoContains, GeoDisjoint, GeoTouches, GeoCrosses, GeoOverlaps, GeoEquals, GeoCovers, GeoCoveredBy, GeoWithinDistance), and measurement/validity accessors, see the Spatial documentation. Map a geometry with cfg.MapSpatialProperty(x => x.Area) where Area is a Geometry?. Each predicate takes an optional orderByDistanceFrom and returns SpatialResult<T>.
Spatial predicates in LINQ (DocumentFunctions, v11+): 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)). Lowers to each engine's native spatial function: SQLite (R*Tree + docdb_st_* UDF), MySQL/DuckDB/PostgreSQL (ST_*; PostgreSQL needs PostGIS), SQL Server (native geometry + .ST*), Oracle (SDO_GEOM, needs Oracle Spatial), CosmosDB (ST_INTERSECTS/ST_WITHIN/ST_DISTANCE), MongoDB ($geoIntersects/$geoWithin). Cosmos/Mongo expose the intersect/within/distance subset in a Where; the finer predicates throw there — the dedicated store.Geo* methods support every predicate on every spatial-capable provider. PortableSpatial = true on a relational provider — or settings.PortableSpatial when wiring through Aspire's AddDocumentStore — forces the dependency-free envelope tier.
// Geographic point (WGS84), serializes as GeoJSON
public readonly record struct GeoPoint(double Latitude, double Longitude);
// Bounding box for area queries
public readonly record struct GeoBoundingBox(
double MinLatitude, double MinLongitude,
double MaxLatitude, double MaxLongitude);
// Query result with computed distance
public class SpatialResult<T> where T : class
{
public required T Document { get; init; }
public double DistanceMeters { get; init; }
}
Register which GeoPoint property to use for spatial indexing per document type:
public class Restaurant
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public GeoPoint Location { get; set; }
public string Cuisine { get; set; } = "";
}
var options = new DocumentStoreOptions
{
DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
};
options.ConfigureDocument<Restaurant>(cfg => cfg.MapSpatialProperty(r => r.Location));
var store = new DocumentStore(options);
For full AOT safety, use the delegate overload:
options.ConfigureDocument<Restaurant>(cfg => cfg.MapSpatialProperty("Location", r => r.Location));
The mapped property may be a nullable GeoPoint? for records with optional coordinates. A document whose
location is null is skipped by the spatial index — it does not throw on insert/update and is never
returned by spatial queries; setting a previously-populated location back to null on update purges its
stale index entry.
// Find documents within a radius (meters), ordered by distance ascending
var nearby = await store.WithinRadius<Restaurant>(
new GeoPoint(45.5231, -122.6765), // Portland, OR
5000, // 5km
filter: r => r.Cuisine == "Italian");
foreach (var result in nearby)
Console.WriteLine($"{result.Document.Name} — {result.DistanceMeters:N0}m away");
// Find documents within a bounding box
var inArea = await store.WithinBoundingBox<Restaurant>(
new GeoBoundingBox(45.0, -123.0, 46.0, -122.0));
// Find K nearest neighbors, ordered by distance
var closest = await store.NearestNeighbors<Restaurant>(
new GeoPoint(45.5231, -122.6765),
count: 10,
filter: r => r.Cuisine == "Italian");
GeoPoint serializes as GeoJSON. Spatial index policies are added to the container automatically. Queries use native ST_DISTANCE and ST_WITHIN functions.Map a ReadOnlyMemory<float> embedding property and query by similarity:
public class Document
{
public Guid Id { get; set; }
public string Content { get; set; } = "";
public ReadOnlyMemory<float> Embedding { get; set; }
}
var options = new DocumentStoreOptions
{
DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
{
EnableVectorExtension = true // load sqlite-vec on every connection
}
};
options.ConfigureDocument<Document>(cfg => cfg.MapVectorProperty(
d => d.Embedding,
dimensions: 1536,
metric: VectorDistance.Cosine,
indexKind: VectorIndexKind.Hnsw));
var store = new DocumentStore(options);
// Top-10 nearest to a query embedding
var hits = await store.Query<Document>()
.Where(d => d.Content.Contains("invoice")) // pre-filter where supported
.NearestVectors(queryEmbedding, k: 10);
foreach (var hit in hits)
Console.WriteLine($"{hit.Score:F4} {hit.Document.Content}");
| Provider | Storage | Index kinds | Filter strategy |
|---|---|---|---|
| PostgreSQL | pgvector sidecar table | HNSW, IVF, None | Pre-filter via JOIN |
| SQL Server 2025 | Native VECTOR(n) sidecar | DiskANN, None | Pre-filter via JOIN |
| Oracle 23ai | Native VECTOR(n, FLOAT32) sidecar | HNSW, IVF, None | Pre-filter via JOIN |
| CosmosDB | Embedded in document JSON | DiskANN, QuantizedFlat, Flat | WHERE + ORDER BY VectorDistance(...) |
| MongoDB (Atlas) | $vectorSearch aggregation | HNSW (Atlas-managed) | Filter clause inside $vectorSearch |
| DuckDB | vss sidecar table | HNSW, None | Pre-filter via JOIN |
| SQLite | sqlite-vec virtual table | None (flat scan) | Post-filter join back |
| MySQL / LiteDB / IndexedDB | — | — | Throws NotSupportedException |
Oracle note:
VECTOR_DISTANCE(exact search) works out of the box. Creating an HNSW/IVF vector index additionally requires the database's vector pool — setvector_memory_size(ALTER SYSTEM SET vector_memory_size = 1G SCOPE=SPFILE;then restart). If the pool isn't configured, index creation is silently skipped and queries fall back to an exact sequential scan (still correct, just unindexed).
SQLite on iOS/Android — use
Shiny.DocumentDb.Sqlite.VectorSupport:EnableVectorExtensioncallssqlite3_load_extension, which cannot work on iOS — Apple forbidsdlopenof loose libraries and the bundlede_sqlite3disables runtime extension loading (Android usually fails too). TheShiny.DocumentDb.Sqlite.VectorSupportpackage ships thesqlite-vecnative binaries (iOSxcframework, Android.so— 16 KB page aligned for Android 15+, unlike the upstreamsqlite-vecrelease artifacts — and desktop loadables) and a one-call helper —opts.DatabaseProvider = SqliteVec.CreateProvider(connStr)— that registersvec0as an auto-extension and setsVectorExtensionPreloaded. To wire it by hand instead, statically linksqlite-vec, register it once withsqlite3_auto_extension(sqlite3_vec_init), and setVectorExtensionPreloaded = true(the provider then usesvec0without callingLoadExtension; if both flags are set, preloaded wins).
| Metric | Surfaced as | Direction |
|---|---|---|
| Cosine | Distance in [0, 2] | Lower = closer |
| Euclidean (L2) | Distance | Lower = closer |
| DotProduct | Raw inner product | Higher = closer (negated internally where needed so ORDER BY score ASC works) |
| Hamming | Bit count | Lower = closer (PostgreSQL only) |
Wire a Microsoft.Extensions.AI.IEmbeddingGenerator so the vector is populated automatically when the source text is set:
using Shiny.DocumentDb.Extensions.AI;
opts.ConfigureDocument<Document>(cfg => cfg.MapVectorProperty(d => d.Embedding, dimensions: 1536));
opts.AutoEmbedOnInsert<Document>(
embeddingGenerator,
sourceSelector: d => d.Content,
targetSetter: (d, vec) => d.Embedding = vec,
targetGetter: d => d.Embedding); // optional: skip when already set
// User writes the text only; the embedding lands in the document on Insert/Upsert/BatchInsert.
await store.Insert(new Document { Content = "hello world" });
Microsoft.Extensions.VectorData connectorShiny.DocumentDb.Extensions.VectorData exposes the store as MEVD's VectorStore for the .NET AI ecosystem. Map each record while configuring the options — DocumentDb fixes its vector mappings when the store is built, so GetCollection only builds a facade:
using Microsoft.Extensions.VectorData;
using Shiny.DocumentDb.Extensions.VectorData;
public class Note
{
[VectorStoreKey] public string Id { get; set; } = ""; // must also be the document id
[VectorStoreData(IsIndexed = true)] public string Tag { get; set; } = "";
[VectorStoreVector(1536, DistanceFunction = DistanceFunction.CosineDistance)]
public ReadOnlyMemory<float> Embedding { get; set; }
}
services.AddDocumentDbVectorStore(o =>
{
o.DatabaseProvider = new SqliteDatabaseProvider("Data Source=app.db") { EnableVectorExtension = true };
o.MapVectorRecord<Note>(); // reads the attributes → MapVectorProperty<Note>
});
var notes = sp.GetRequiredService<VectorStore>().GetCollection<string, Note>("Note");
await notes.UpsertAsync(note);
await foreach (var hit in notes.SearchAsync(queryEmbedding, top: 5,
new VectorSearchOptions<Note> { Filter = n => n.Tag == "release" }))
{
Console.WriteLine($"{hit.Record.Id} — {hit.Score}");
}
[VectorStoreKey] must also be the document's id (Id by convention, otherwise cfg.MapIdProperty) and be string/Guid/int/long; one [VectorStoreVector] per record. UpsertAsync is a full replace, IncludeVectors defaults to false, and results are ordered nearest-first on every provider while the raw score keeps its backend-specific direction. GetDynamicCollection and hybrid keyword+vector search throw. For a store registered elsewhere — or on a provider with its own options class (Mongo/Cosmos/Redis) — use the no-argument services.AddDocumentDbVectorStore(), or new DocumentDbVectorStore(store) for the container-free path.
VectorIndexOptions exposes the common ANN parameters and a ProviderHints dictionary for the long tail:
opts.ConfigureDocument<Document>(cfg => cfg.MapVectorProperty(
d => d.Embedding,
dimensions: 1536,
metric: VectorDistance.Cosine,
indexKind: VectorIndexKind.Hnsw,
configureIndex: i =>
{
i.HnswM = 16;
i.HnswEfConstruction = 64;
i.HnswEfSearch = 40;
i.ProviderHints["sqlite.postFilterMultiplier"] = 4;
i.ProviderHints["atlas.indexName"] = "my-vec-index";
}));
Recognized hints:
sqlite.postFilterMultiplier (int): candidate count multiplier when a Where post-filter is applied.atlas.indexName (string): Atlas Vector Search index name (default vector_index_{type}).atlas.numCandidates (int): Atlas numCandidates (default 10 * k).Full design document: docs/vector-support.md.
For frequently queried JSON properties, you can create expression indexes to speed up lookups. These methods are on the concrete DocumentStore (not on IDocumentStore) since index management is DDL, not document CRUD. Each provider generates the appropriate index DDL for its database engine.
await store.CreateIndexAsync<User>(u => u.Name, ctx.User);
This generates a partial index scoped to the document type:
CREATE INDEX IF NOT EXISTS idx_json_User_name
ON documents (json_extract(Data, '$.name'))
WHERE TypeName = 'User';
await store.CreateIndexAsync<Order>(o => o.ShippingAddress.City, ctx.Order);
await store.DropIndexAsync<User>(u => u.Name, ctx.User);
Removes all idx_json_ indexes for the given type while preserving built-in indexes and indexes on other types.
await store.DropAllIndexesAsync<User>();
Index names are deterministic (idx_json_{typeName}_{jsonPath} with dots replaced by underscores), so CreateIndexAsync and DropIndexAsync always agree on the name for a given expression. CreateIndexAsync uses IF NOT EXISTS, so calling it multiple times is safe.
ShinyDocDbMyAdmin is a phpMyAdmin-style front end for DocumentDb stores. Connect to a
database, browse the documents in it, edit them, query it with the filter grammar or raw SQL, manage JSON indexes, ask an AI assistant about the data, and move data in and
out. No user management, no server administration — just the documents.
It comes in two front ends over one admin layer. The web one ships as a container image, on GHCR and mirrored byte-for-byte to Docker Hub:
docker run -p 8085:8080 -v shiny-docdb-myadmin:/data ghcr.io/shinyorg/shiny-docdb-myadmin
# or: docker run -p 8085:8080 -v shiny-docdb-myadmin:/data aritchie/shiny-docdb-myadmin
On Docker Desktop there is a one-click way in — a DocumentDb Admin tab that starts that container, runs the app inside the tab, and hands it every database container already on your machine, connected:
docker extension install aritchie/shiny-docdb-myadmin-extension
The terminal one ships as a dotnet tool (~48MB packed — mostly the SQLite/SQLCipher native binaries for
every platform):
dotnet tool install -g ShinyDocDbMyAdmin.Tui
shinydocdb
In an Aspire 13.5+ AppHost that terminal tool is a resource of its own —
builder.AddDocumentDbAdminTerminal().WithReference(store).WithStartupProfile(store), attached to with
aspire terminal attach documentdb-terminal — which for a local AppHost replaces the container entirely,
and is the only one of the two that opens a SQLite/SQLCipher file on your machine without a bind
mount. It does not deploy (terminal sessions are a dev-loop feature, so the resource stays out of the
manifest).
They share the connection store, the secret handling and the bundle format — ShinyDocDbMyAdmin.Core
holds the profiles, the document/schema/index/sidecar services and import/export — so a connection saved
in one is a connection the other opens, and a bundle written by either imports into the other. The
feature list is not a subset: it is the same list. Only the things the medium decides differ (a browser
uploads bytes where a terminal takes a path; geometry is an SVG map there and braille cells here).
It covers every relational backend — SQLite, SQLCipher, PostgreSQL, SQL Server, MySQL,
MariaDB, Oracle 23ai+, CockroachDB. The document stores (MongoDB, Cosmos, LiteDB, IndexedDB, …) are
deliberately out of scope: the tool works against the shared
Id / TypeName / Data / CreatedAt / UpdatedAt envelope over ADO.NET, which only the relational
providers expose. File-backed databases can be uploaded through the UI rather than referenced in
place.
| Tab | What it does |
|---|---|
| Browse | Paged, sortable grid with columns inferred by sampling documents. Filters on any envelope column or JSON path (=, ≠, contains, starts/ends with, comparisons, null checks) plus a quick search across string fields. Numeric filters and sorts compare numerically, so 9 < 10 rather than "9" > "10". Any row expands into a syntax-highlighted, collapsible view of its whole body. |
| Edit | JSON editor with format and validation. Insert, edit, duplicate-by-id, single and bulk delete. Writes carry the sidecars with them: blob rows are cleared on delete, a temporal type records the version, and a vector-mapped type has its embedding re-indexed. |
| Structure | The inferred shape of a type (paths, types, how often each field is actually present, examples), row and size statistics, and one-click create/drop of JSON property indexes — named exactly as the library names its own, single-path or composite. Lists every index on the table, not only DocumentDb's, with size and planner-usage counts where the engine tracks them and an unused flag where it doesn't get used. |
| Full text | Appears when a type is actually full-text indexed. Ranked search through the provider's own engine (FTS5 BM25, ts_rank, FREETEXTTABLE, Oracle CONTAINS), needing no registered mapping — every provider derives the index from the table and type name. Unlike the vector sidecar there is nothing to keep in sync: these indexes are engine-maintained, so a document edited here is immediately searchable. |
| History | Appears for a cfg.MapTemporal-mapped type. An audit log across the type, then every version of one document — operation, actor, the interval it was current, and how long it stood. Compare any two versions as a field-by-field change list (added / removed / changed, by dotted path) or side by side, and restore a prior version behind a confirm. |
| Geometry | Appears when a type stores GeoJSON. Renders the geometries on an SVG map with zoom-to-feature, and lists vertices, length, area, centroid and OGC validity per document. Read from the document body rather than the spatial sidecar (which holds only bounding boxes), so it works on every provider — including those with no spatial support at all. |
| Vectors | Appears when a type stores embeddings. Dimensions, L2 norms and how many are unit length, plus the failure modes that never raise an error — all-zero vectors, NaN/infinity components, mixed dimensions. Reconciles the sidecar against the documents (embeddings missing from the index, rows pointing at deleted documents) with a one-click rebuild from the bodies. Nearest-neighbour search from a document's own embedding or a pasted vector, computed in the tool rather than pushed down — so it is exact rather than approximate, and works when the sidecar is the thing you suspect. |
Mark a connection read-only and every write path is blocked, including non-SELECT statements in the SQL console. The SQL console is otherwise exactly what it looks like — an open prompt against the database, with no statement allow-list — so give the tool a database account with the privileges you actually want it to have.
Writes to a temporal-mapped type record a version, appending to the same {table}_history sidecar
with the same SQL the library uses, attributed to the actor shiny-docdb-myadmin. The tool writes
SQL directly — it sits below IDocumentStore because it has no CLR type to bind to — so without
this, editing a document here would change the row and leave history insisting the old body was
still current. (Clear is the exception, matching the library: a bulk delete is the one mutation
temporal tracking skips.)
The same applies to the vector sidecar, with one caveat: on SQLite it is a vec0 virtual table that
needs the sqlite-vec extension, which the library loads and the admin container does not ship. The
tool probes the sidecar before it opens a transaction and, where it cannot write it, reports a stale
index rather than pretending it wrote one — the Vectors tab shows the drift and rebuilds from the
document bodies wherever the write path is available.
ShinyDocDbMyAdmin:DemoMode turns the 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, then closes editing, connection management, data import, settings transfer, the AI
assistant, and any way to rebuild the sample. Exporting data stays open — it only reads, and it
is most of what someone is at a playground to try.
docker run -p 8085:8080 -v shiny-demo:/data ghcr.io/shinyorg/shiny-docdb-myadmin:demo
:demo is the ordinary image with the flag already set — the same build, one extra layer, every other
layer shared. Setting ShinyDocDbMyAdmin__DemoMode=true on :latest does the same thing.
The sample is written once and never rebuilt, so an image update cannot discard a database someone is looking at. Every closure is enforced below the UI — the profile store refuses the save, the import service refuses the import — rather than only by hiding a button. A band across the top of the app says the instance is a demo and lists exactly what is switched off.
The Assistant reads every table in every connection configured in the tool — cross-connection
questions are the point — and what it reads goes to the AI provider you configured. Nothing is sent
until you send a message: there is no background indexing, no schema pre-fetch and no telemetry, so
configuring the assistant does not by itself transmit anything. 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. Set ShinyDocDbMyAdmin:DisableAi for a public demo instance and the feature is
absent rather than merely hidden: the services are not registered and the routes refuse to render.
Shiny.DocumentDb.Aspire.Hosting models the admin UI as a resource, so it comes up with the rest of
your app and every store you reference is already connected:
var store = builder.AddPostgresDocumentStore("orders");
builder.AddDocumentDbAdmin(port: 8085)
.WithReference(store)
.WaitFor(store);
WithReference is the same call a consuming service makes — the admin UI reads the
ConnectionStrings:{name} + Shiny:DocumentDb:{name}:Provider pair the hosting integration already
emits. A connection string with no matching provider key is ignored, so a Redis or blob reference in
the same AppHost doesn't turn into a junk connection. Referenced stores show up under a from host
badge and can't be edited or deleted from the UI — they're declared in the AppHost, so that's where
they change.
It's the same container either way — running the AppHost and publishing it — tagged to match the hosting package's own version, so an integration upgrade brings the matching UI with it.
| Method | What it does |
|---|---|
AddDocumentDbAdmin(name, port) | Adds the UI. port is the host port you browse to; omit it and Aspire picks a free one. |
WithHostPort(port) | The same, after the fact. null hands the choice back to Aspire. |
WithReadOnly() | Blocks every write path, across every store the AppHost hands over. |
WithoutAi() | Removes the Assistant entirely — no tab, no settings page, routes refuse to render. For an AppHost publishing a public demo. |
WithDataVolume(name) | Keeps saved connections, saved queries and uploaded files in a named volume, so they survive the container. Without it, anything saved in the UI is gone at the end of the run. |
WithHostPath(hostPath, containerPath) | Mounts a directory from your machine — how a file-backed store becomes reachable at all. |
WithSecretKey(key) | Key (or ParameterResource) encrypting the secret-bearing parts of a saved connection. |
Because it's a container, the databases the AppHost models are reachable over the container network — Aspire wires that up. A file on your machine is not: a SQLite, SQLCipher or DuckDB store at a host path is invisible inside the container unless you mount it, and the connection string has to use the path as the container sees it.
var store = builder.AddSqliteDocumentStore("orders", "/data/orders.db");
builder.AddDocumentDbAdmin()
.WithHostPath("/Users/me/databases", "/data")
.WithReference(store);
Outside Aspire the same settings are plain configuration: ShinyDocDbMyAdmin:DataDirectory
(SHINYDOCDBMYADMIN_DATA), ShinyDocDbMyAdmin:SecretKey (SHINYDOCDBMYADMIN_KEY),
ShinyDocDbMyAdmin:ReadOnly, ShinyDocDbMyAdmin:DisableAi (which removes the Assistant entirely)
and ShinyDocDbMyAdmin:DemoMode (see below). SecretKey encrypts saved connection strings,
SQLCipher keys and AI API keys with
AES-GCM and takes either a base64 32-byte key or a passphrase; set it out of band for any shared
deployment, because with no key configured a random one is generated next to the database it
protects — which guards against a stray backup or synced folder, but not against anyone who can read
the data directory.
The following LINQ expressions are supported across all providers. SQL output shown uses SQLite syntax; other providers generate equivalent SQL using their native JSON functions.
| Expression | SQL Output (SQLite) |
|---|---|
u.Name == "Alice" | json_extract(Data, '$.name') = @p0 |
u.Age > 25 | json_extract(Data, '$.age') > @p0 |
u.Age == 25 && u.Name == "Alice" | (... AND ...) |
u.Name == "A" || u.Name == "B" | (... OR ...) |
!(u.Name == "Alice") | NOT (...) |
u.Email == null | ... IS NULL |
u.Email != null | ... IS NOT NULL |
u.Name.Contains("li") | ... LIKE '%' || @p0 || '%' |
u.Name.StartsWith("Al") | ... LIKE @p0 || '%' |
u.Name.EndsWith("ob") | ... LIKE '%' || @p0 |
o.ShippingAddress.City == "X" | json_extract(Data, '$.shippingAddress.city') = @p0 |
o.Lines.Any(l => l.Name == "X") | EXISTS (SELECT 1 FROM json_each(...) WHERE ...) |
o.Tags.Any(t => t == "priority") | EXISTS (SELECT 1 FROM json_each(...) WHERE value = @p0) |
o.Tags.Any() | json_array_length(Data, '$.tags') > 0 |
o.Lines.Count() > 1 | json_array_length(Data, '$.lines') > 1 |
o.Lines.Count(l => l.Qty > 2) | (SELECT COUNT(*) FROM json_each(...) WHERE ...) > 2 |
e.StartDate > cutoff | json_extract(Data, '$.startDate') > @p0 (ISO 8601 formatted) |
e.CreatedAt >= start | json_extract(Data, '$.createdAt') > @p0 (DateTimeOffset supported) |
| Captured variables | Extracted from closure at translate time |
| Expression | SQL Output |
|---|---|
x => new R { A = x.Name } | json_object('name', json_extract(Data, '$.name')) |
x => new R { C = x.Nav.Prop } | json_object('c', json_extract(Data, '$.nav.prop')) |
x => new R { N = x.Lines.Count() } | json_array_length(Data, '$.lines') |
x => new R { N = x.Lines.Count(l => ...) } | (SELECT COUNT(*) FROM json_each(Data, '$.lines') WHERE ...) |
x => new R { B = x.Tags.Any() } | CASE WHEN json_array_length(...) > 0 THEN json('true') ELSE json('false') END |
x => new R { B = x.Tags.Any(t => ...) } | CASE WHEN EXISTS (SELECT 1 FROM json_each(...) WHERE ...) THEN json('true') ELSE json('false') END |
Shipping this to production? ShinySoft Technologies, the company that builds and maintains the Shiny libraries, offers priority issue response, support agreements, architecture reviews and private office hours. See Commercial Support or get in touch. Community support through GitHub issues and discussions stays free.
INCRAddRavenDbDocumentStore(...)ToQueryStringAddFirestoreDocumentStore(...)AddDocumentDbDocumentStore(...)retryWrites=false$text$vectorSearchstore.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.First() / FirstOrDefault() / Single() / SingleOrDefault(), with predicate (First(u => u.Age == 40)) and string-filter (First("status == 'open'")) overloads. The row limit reaches the provider — relational stores emit LIMIT 1, MongoDB/Cosmos page server-side — so a single-document read never materializes the whole match set. Single fetches two rows so "more than one matched" costs no extra round trip.Query<T>() with JSON instead of T, so a document read only to be written back out never becomes an object. store.Query<Order>().Where(o => o.Id == id).FirstOrDefaultRawJson() hands back the stored body unparsed for Results.Content(raw, "application/json"), and .WriteJsonArrayTo(ctx.Response.Body, ct) streams the whole match set into an HTTP response as one array — never buffered, never re-serialized. A node lane returns JsonObjects instead (ToJsonList, ToJsonAsyncEnumerable, FirstJson/FirstOrDefaultJson, SingleJson/SingleOrDefaultJson, ToJsonCursorPage), all over the RawJsonRows(maxRows) primitive. 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. Available on every provider: the relational stores and Cosmos DB hand back the persisted body untouched, while the rest must materialize T to finish the query and re-serialize through the type's JsonTypeInfo (same JSON, same API, no saving). Types with encrypted properties throw — the stored body is ciphertext and only the typed terminals decrypt.store.Query<User>().Where(u => u.Age > 30).ToQueryString() returns the SQL (or MongoDB BSON) the query would run, with its parameter values, without executing it — for debugging and logging. Relational providers and Cosmos return SQL + parameters; MongoDB returns the rendered BSON filter; LiteDB/IndexedDB throw NotSupportedException.Where predicates translate a library of functions to each backend's native form: string functions (ToLower/ToUpper, Length, Trim, Substring, Replace, IndexOf, string.IsNullOrEmpty), Math.* (Abs, Round, Ceiling, Floor, Sqrt, Pow, Sign), date-part access, flag-enum tests (x.Permissions.HasFlag(Permissions.Write) and (x & flag) == flag), and phonetic search via DocumentFunctions.Soundex(...). Relational providers emit native SQL (BITAND on Oracle); MongoDB uses $expr (+ $bitsAllSet); CosmosDB uses native NoSQL functions; LiteDB/IndexedDB run in-memory. Register custom translations with MapFunctionTranslation(...). Built on a shared, AOT-safe query IR (the whole query surface is free of Expression.Compile()).IAsyncEnumerable<T> streaming — yield results one-at-a-time with .ToAsyncEnumerable() instead of buffering into a list. Eliminates Gen1 GC pressure at scale with comparable throughput.store.CreateIndexAsync<User>(u => u.Name, ctx.User) creates a partial JSON index on the property. Up to 30x faster queries on indexed properties. (SQLite uses json_extract; other providers use native JSON indexing.)cfg.MapUniqueIndex(x => x.Email) makes a value unique per document type; composite keys are an anonymous type (x => new { x.Region, x.AccountNumber }), nested properties work, and cfg.MapProperty(x => x.Sku, p => p.Unique()) is the single-property shorthand. A filter: constrains only the documents it matches — filter: x => !x.IsDeleted is the natural pairing with soft delete — and is re-evaluated on every write. A violating Insert/Update/Upsert/SetProperty/ExecuteUpdate/batch/session write stores nothing and throws UniqueConstraintException (type, index, properties and document id — never the duplicated value, which is often personal data). The rules are identical everywhere: scoped to the type (other types in a shared table may reuse the value) and, with shared-table multi-tenancy, to the tenant; a null or missing key part is unconstrained; values compare exactly, so normalize first for case-insensitive uniqueness. Relational providers (except DuckDB, which can't index a JSON expression and rejects the mapping at startup) and MongoDB/Amazon DocumentDB create a native unique index when the table/collection is initialized (partial where the engine has one, a NULL-folded or hashed key where it doesn't); Cosmos DB, LiteDB, IndexedDB, DynamoDB, Azure Table, Firestore, Redis and RavenDB keep their own index entries in step with each write.json_object at the database level via .Select(). No full document deserialization needed.store.Query<Order>().GroupBy(o => o.Status).Having(g => g.Sum(o => o.Total) > 10_000).Select(g => new StatusRollup { Status = g.Key, Count = g.Count(), Revenue = g.Sum(o => o.Total) }) rolls up one row per key with g.Key and the Sql group aggregates (g.Count/Sum/Avg/Min/Max). Keys can be nested, derived (o => o.CreatedAt.Month), or a multi-column anonymous type; Having filters groups; grouped OrderBy/Paginate/Count/ToQueryString all flow through, and the string grammar (GroupBy("status").Project("status, count() as n, sum(total) as revenue")) lowers to the same SQL. Push-down on the relational providers; MongoDB/Cosmos/LiteDB/IndexedDB group client-side; Azure Table/DynamoDB throw. The whole-set Count/Sum/Average terminals remain for ungrouped totals.JsonTypeInfo<T> parameter for source-generated JSON serialization. No reflection required. Configure a JsonSerializerContext once and all methods auto-resolve type info — no per-call JsonTypeInfo<T> needed. Set UseReflectionFallback = false to catch missing type registrations with clear exceptions instead of opaque AOT failures. Every shipping package builds trim/AOT warning-free and is marked IsAotCompatible, so a consumer's trimmer actually trims into it; the mapping APIs (cfg.MapVersionProperty, cfg.MapSpatialProperty, cfg.MapVectorProperty, cfg.MapFullTextProperty, cfg.MapComputedProperty, cfg.MapBlob) annotate their T with [DynamicallyAccessedMembers] so the properties they resolve by name survive. This is verified on every build by samples/Sample.Aot, which publishes with PublishAot=true and treats any trim/AOT warning as an error — not just static analysis, a real ILC run. Two permanent limitations are documented: grouped projections need a named type with a JsonTypeInfo (anonymous types cannot have one), and string-query parameters should use IDictionary<string, object?> rather than an anonymous bag.DocumentContext — an optional EF-Core-style typed front-end (the source generator is bundled in the core package — no extra package to install). Declare aggregates once on a partial context ([Document(typeof(User), JsonContext = typeof(AppJsonContext))]) and the source generator emits a DocumentSet<T> per type, a ConfigureModel lowering, and two DI extensions — services.AddAppContext(...) (scoped, for ASP.NET Core) and services.AddAppContextFactory(...) (a singleton IDocumentContextFactory<AppContext> for MAUI/Blazor/desktop, mirroring EF Core's IDbContextFactory<T>). Work model-first — await db.Users.Where(u => u.Age >= 18).ToList(), await db.Users.Insert(user) — with JsonTypeInfo<T> threaded automatically, so you never re-type <T> or pass type metadata. Ergonomics + discoverability only (no change tracking / navigation / Include); immediate writes that join the context's transaction while one is open (await using var tx = await db.BeginTransaction(); — the sets' reads, writes, Clear and Batch* calls commit or roll back with it); the context is itself a unit of work. Per-type serialization knob: JsonContext (point at your JsonSerializerContext) or Generated (the generator emits the metadata-mode JsonTypeInfo itself) for AOT, Reflection to opt out. Works over any provider — DocumentContext only needs an IDocumentStore.public {Guid|int|long|string} Id { get; set; } property (or a custom Id type registered via MapIdType). Ids are auto-generated when default (Guid.Empty, 0, null/empty string) and written back to the object. The Id lives in both the SQLite column and the JSON blob, so query results always include it.store.Upsert(patch) deep-merges a partial object into an existing document using SQLite's json_patch() (RFC 7396). The Id comes from the object. Only patched fields are overwritten; unset nullable fields are preserved. The out-of-body sidecars follow the same rule — a patch that omits a mapped blob, geometry or embedding leaves the stored one and its sidecar row untouched, so a partial patch never re-sends a payload and never drops the document out of spatial/vector search. The corollary: a merge can't clear one (a null is stripped from the patch) — use a write that carries the whole document, Update or Upsert(doc, patchIfUpdate: false). (A mapped vector is a non-nullable ReadOnlyMemory<float> that serializes as [] when unset, so the merge drops the empty array too — otherwise a partial patch would silently overwrite the stored embedding.)Update(doc, patch: true) deep-merges instead of replacing (update-only), and Upsert(doc, patchIfUpdate: false) replaces wholesale instead of merging (insert-or-replace) — the same flags on the JSON lane (Update(type, jsonObject, patch: true)) give precise partial updates. The two defaults (Update replace, Upsert merge) work everywhere; the non-default modes are relational-provider (+ JSON lane) only.store.SetProperty<User>("id", u => u.Age, 31) updates a single JSON field via json_set() without deserializing the document. store.RemoveProperty<User>("id", u => u.Email) strips a field via json_remove(). Both support nested paths like o => o.ShippingAddress.City.Query<Order>().Where(...).ExecuteUpdate(b => b.Set(o => o.Status, "expired").Set(o => o.ClosedAt, now)) writes every assignment in one statement: relational providers nest the dialect's JSON-set expression, MongoDB combines them into one $set, Cosmos applies them in the same read-modify-write pass. One predicate evaluation, atomic on its own.store.Query<Order>().Join<Customer>((o, c) => o.CustomerId == c.Id, JoinKind.Left).Where((o, c) => c.Region == "eu" && o.Total > c.CreditLimit).OrderBy((o, c) => c.Name).Paginate(0, 50).Select((o, c) => new { o.Id, Customer = c == null ? null : c.Name }).ToList() runs as one engine query: inner or left, Where over either side or across both, ordering and paging in the engine, and Select into any shape over the materialized documents (so anonymous types work and encrypted properties read as plaintext). Where clauses on the source query filter the left side; each side keeps its own global query filters and tenant scope, and the right side's go in the join condition so a soft-deleted match leaves a left join's row in place. The string form is Join<Customer>("o", "c", "o.customerId = c.id").Where("c.region = 'eu'").Project("o.id, c.name as customer") — every field alias-qualified (a LINQ join's parameter names are its aliases), and the comparison grammar accepts a field on the right-hand side everywhere. An encrypted property filters its own side but cannot be a join key; spatial, full-text, computed properties and GroupBy stay single-document. The relational providers emit one INNER/LEFT JOIN statement and MongoDB one $lookup aggregation; Amazon DocumentDB (no correlated $lookup), Cosmos DB, LiteDB, IndexedDB and the key-value stores throw NotSupportedException.store.GetDiff("id", modified) compares an object against the stored document and returns an RFC 6902 JsonPatchDocument<T> with deep nested-object diffing. Powered by SystemTextJsonPatch.Get, Remove, SetProperty, and RemoveProperty accept the Id as object so you can pass a Guid, int, long, or string directly. Unsupported types throw ArgumentException.store.Collection("orders") is schema-free: no CLR type, no registered mappings, no migrations — the name becomes the row's TypeName, so it shares a table with your typed documents without either seeing the other, and needs zero schema change. store.Collection(typeof(Order)) (or store.Collection<Order>()) is late-bound: a registered document type but no instance, for generic HTTP intake, message-bus payloads, ETL and gateways. Both expose Insert (a JsonObject returns the stored id; a JsonArray writes many atomically and returns the count), Update/Upsert with the same patch / patchIfUpdate flags, Get, Remove, BatchRemove, Clear, CreateIndex, a raw-SQL Query(whereClause, parameters) escape hatch, and a fluent string-grammar Query() returning JsonObjects with Where/OrderBy/Paginate/Project/GroupBy/ToCursorPage/ExecuteUpdate/ExecuteDelete. The body is stored AS-IS. A type-keyed collection rides the full write pipeline (tenancy, temporal history, versioning/CAS, spatial + vector + blob sidecars, interceptors, change notifications) and resolves field paths through the type's metadata, so it reaches the whole grammar including hasflag, the geo predicates and the Lucene functions. A name-keyed collection has none of those registrations — tenancy, transactions, paging, cursors and backup still apply — and instead infers each field's type as the query is built: from an explicit total:number hint, else from the other operand (total > 100 is numeric), else from the function (lower(name) is a string), else string. A hint is required for OrderBy/min/max/Project over a numeric field, because an unhinted extract sorts lexicographically ("100" before "9") on every provider except SQLite. Ids differ by target: schema-free generates a sortable UUIDv7 string into a configurable property (read case-insensitively, written verbatim, compared as a literal string), while type-keyed follows the type's own IdKind (Guid → v4, int/long → sequence, declared string → refuses to auto-generate). Collection names and field paths must be identifiers (^[A-Za-z_][A-Za-z0-9_]{0,127}$, max 16 dot-separated segments) because they reach SQL as literals — so keys with dashes, spaces or dots are storable but not addressable. Relational providers only (SQLite, SQLCipher, DuckDB, PostgreSQL, CockroachDB, SQL Server, MySQL, MariaDB, Oracle); the document-native and key-partitioned providers throw NotSupportedException, and collections are a store-level feature reached from a session via session.Store.Collection(...).store.Query<User>().OrderBy(u => u.Name).Paginate(0, 20).ToList() translates to SQL LIMIT/OFFSET. For UI/REST responses use .PageResult(page, pageSize) to get back a PagedResults<T> { Records, TotalCount, Page, PageSize } in one call — the total reflects the current Where filters, not just the returned slice.store.Query<Order>().OrderByDescending(o => o.CreatedAt).ToCursorPage(cursor, take: 50) returns a CursorPage<T> { Items, NextCursor, HasMore }: a forward-only seek that stays O(log n) per page (with an index on the sort key) and is stable under concurrent writes — no ever-growing OFFSET, no total-count round-trip. The keyset is derived from the query's own OrderBy (an Id tiebreaker is appended automatically); ToCursorStream(pageSize) walks every page as an IAsyncEnumerable<T>. Keyset seeks server-side on the relational providers; LiteDB/IndexedDB/MongoDB page it client-side; Cosmos/DynamoDB/Azure Table are not yet supported (throw).store.Query<User>().OrderBy("Name", ctx.User) resolves the property through JsonTypeInfo<T> (source-generated), so the sort column can be supplied at runtime — from a query string, a column-header click, etc. Matches CLR or JSON name (case-insensitive), supports dotted paths like "ShippingAddress.City", and never uses reflection on T.DocumentMetadata, every provider) — declare public DocumentMetadata? Metadata { get; set; } and the store fills in CreatedAt, UpdatedAt and IsPersisted from the timestamps it already keeps next to the body — plus TenantId, the owning tenant under shared-table multi-tenancy (TenantIdAccessor, relational providers; null otherwise, not queryable): on every read, and on the instance you pass to Insert/Update/Upsert after the write, with no re-read. No mapping call — the property is found by type and newed up when null, so a stored document never has a null Metadata. It is never written into the body (the envelope stays the only copy), and Where/OrderBy on x.Metadata.CreatedAt/UpdatedAt — or the string grammar's Metadata.CreatedAt — run against the envelope: pushed down on the relational providers, MongoDB, Amazon DocumentDB, Cosmos DB, Firestore, Azure Table and DynamoDB, evaluated in memory on Redis, RavenDB, LiteDB and IndexedDB. Temporal snapshots new it up unstamped (IsPersisted == false).cfg.MapVersionProperty(x => x.RowVersion) enables automatic version checking on update/upsert. Version is set to 1 on insert, checked and incremented on update. Throws ConcurrencyException on conflict. Works across all providers — stored in the JSON blob with zero schema changes.IObservableDocumentStore) — consume an IAsyncEnumerable<DocumentChange<T>> of insert/update/remove/clear notifications with await foreach (var c in store.NotifyOnChange<User>(ct)) { ... } to drive reactive UI from your own writes. Notifications are in-process (changes made through this store instance), buffered and emitted only on commit. Supported on SQLite, SQLCipher, MySQL, SQL Server, PostgreSQL, Oracle (the relational DocumentStore) and LiteDB. Use WhenDocumentChanged<T>(id) to watch a single document..NotifyOnChange() on any query to receive only the changes whose document matches the query's Where predicates: await foreach (var c in store.Query<Order>().Where(o => o.Status == "Pending").NotifyOnChange(ct)) { ... }. Property-level / removal / clear events that don't carry the document body are passed through so the consumer can re-check membership.cfg.AddQueryFilter(u => !u.IsDeleted) registers a predicate that's automatically AND-applied to every query of User — including Query<T>(), single-doc paths (Get/Update/Remove/SetProperty/RemoveProperty/Clear), bulk operations (ExecuteUpdate/ExecuteDelete), and per-query change monitoring. Named filters can be disabled individually via query.IgnoreQueryFilters("name"); all filters can be disabled with query.IgnoreQueryFilters(). Use for row-level security or "active only" scopes. Insert is intentionally unfiltered (matches Entity Framework Core).cfg.AddSoftDelete(x => x.IsDeleted) turns Remove/ExecuteDelete/Clear into flag updates and hides flagged documents from every read; Query<T>().IncludeDeleted() / OnlyDeleted() read past it, with Restore / PurgeDeleted / HardDelete for the rest of the lifecycle. Flag is a bool or a nullable DateTime/DateTimeOffset (stamped with now). Not baked into the stores — a query filter plus a cancelling interceptor, shipped as an extension method, on every provider.ctx.Cancel() in BeforeWrite (or BeforeBulkWrite) means "I did this write myself": the store issues nothing, no after-hook or change notification fires, and the caller still gets a normal result. DocumentBulkContext.QueryAs<T>() hands back the originating query so a set-based delete can be re-issued as an update. This is what lets an interceptor change the shape of an operation — the mechanism soft delete is built on.cfg.MapTemporal(o => { o.Retention = TimeSpan.FromDays(90); o.MaxVersions = 50; o.CaptureActor = () => userId; }) opts a type into append-only versioning. Scope-aware actor: o.ResolveActor = sp => sp.GetService<ICurrentUser>()?.Id captures "who" from the write's request-scoped DI (per unit of work), taking precedence over CaptureActor. Every Insert/Update/Upsert/Remove/SetProperty/RemoveProperty/BatchInsert (including inside a session) records a snapshot to a per-type history sidecar. Read it back with History<T>(id), AsOf<T>(id, when), Restore<T>(id, version), GetDiffBetween<T>(id, from, to), plus fleet-wide AsOfAll<T>(when), ChangesByActor<T>(actor), and ChangesBetween<T>(from, to). Opt-in per type (non-temporal types pay nothing); supported on every provider — all relational (SQLite, SQLCipher, PostgreSQL, SQL Server, MySQL, Oracle, DuckDB) and the document stores (LiteDB, MongoDB, CosmosDB, IndexedDB). The history methods live on the ITemporalDocumentStore capability interface (not IDocumentStore) — resolve or cast to it. Retention pruned on every write.DocumentBlob (single) or DocumentBlobCollection (many) with cfg.MapBlob / cfg.MapBlobCollection, and the bytes go to a {table}_blobs sidecar table while only the metadata (length, content type, file name) rides along in the document JSON — so ordinary queries never drag the payload along, and you can Where/OrderBy on that metadata like any other property (Where(x => x.Pdf.Length > 1_000_000)). 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, store.GetBlob<T>(id, key) with no document in hand); Bytes throws until loaded, so there's no hidden I/O behind a property getter. Writes go through the document (assign the member and save — no SetBlob), so the inline metadata can never disagree with the stored bytes; deleting a document cascades to its blob rows. Assigning null clears a blob on a replacing write (Update, Upsert(patchIfUpdate: false)); a merge Upsert strips the null and keeps it. Opt-in SHA-256 hashing and a per-mapping size ceiling; store.MaxBlobSize reports the provider limit. Use it instead of a raw byte[] property (which is base64'd into the document body and read on every query). Relational providers only (SQLite, PostgreSQL/CockroachDB, SQL Server, MySQL/MariaDB, Oracle, DuckDB); NoSQL providers report MaxBlobSize == 0 and throw.IChangeFeedDocumentStore) — observe changes from any writer (other processes/connections), not just this instance: await using var sub = await store.SubscribeChanges<User>(async (change, ct) => { ... });. Backed by each database's own mechanism — PostgreSQL LISTEN/NOTIFY (row-level triggers, true push), SQL Server Change Tracking with optional SqlDependency query-notification wake-ups (configurable via SqlServerChangeFeedOptions), and CosmosDB Change Feed. Provisioning (triggers / enabling change tracking) is automatic and idempotent. Dispose the returned handle to stop. (SQLite, LiteDB, IndexedDB, MySQL and Oracle have no proper external-change mechanism and throw NotSupportedException.)DocumentStore instance backed by PostgreSQL, MySQL, SQL Server, or Oracle opens a fresh connection per operation and lets the ADO.NET driver pool multiplex callers. No per-store semaphore. SQLite and DuckDB (embedded engines that lock the whole DB on writes) keep the long-lived shared connection + serialization model. Providers opt in to shared mode via IDatabaseProvider.RequiresSingleConnection. Table init is exactly-once per table across concurrent first-touch callers (ConcurrentDictionary<string, Lazy<Task>>).IDocumentSession) — the document store is a connection: open a short-lived session and group writes into one transaction: await using var session = store.OpenSession(); session.Add(a).Update(b).Remove<C>(id); await session.SaveChanges(); with automatic commit/rollback. Contiguous same-type runs of inserts, upserts, updates, and removes are each coalesced into the matching batch method. Inject IDocumentSession (scoped) in ASP.NET via AddScopedDocumentSession(), or open one from the singleton IDocumentSessionFactory (MAUI/desktop/background — mirrors EF's IDbContextFactory). For finer control the session exposes an explicit transaction — await using var tx = await session.BeginTransaction(); — for locking reads (session.Get(id, LockMode.Update)) and grouping ExecuteUpdate/ExecuteDelete; SaveChanges joins it. A locking read emits the engine's real row-locking syntax (FOR UPDATE, MariaDB LOCK IN SHARE MODE, SQL Server WITH (UPDLOCK, HOLDLOCK)) so a second locker blocks until the first commits — check store.SupportsPessimisticLocking, which is false on SQLite/DuckDB, where the transaction boundary locks the whole database instead. Pass an IsolationLevel (BeginTransaction(IsolationLevel.Snapshot)) for a consistent-read session. (Replaces the removed CreateUnitOfWork()/UnitOfWork.)SaveChanges(suppressInterceptors: true)) — commit a session with no interceptor (per-document or bulk) firing for that transaction. Bounded by the commit (writes outside the unit still fire interceptors), so it's the right tool for mirrored / authoritative writes that should carry no side effects — bulk import, seeding, migration, and the inbound apply path of Shiny.DocumentDb.AppDataSync. When suppressed the multi-row batch fast path is re-enabled (it's only disabled to guarantee per-doc interceptors fire — moot when none will).IDocumentInterceptor.BeforeWrite read ctx.GetJson() / ctx.GetJsonDocument() to see the exact JSON about to be persisted (the store's own options/JsonTypeInfo, cached, invalidated if an earlier interceptor replaces the document). Generally useful for auditing, redaction checks, and outbound capture; it's the primitive the Shiny.DocumentDb.JsonSchema validation package builds on.store.BatchInsert(items) inserts a collection in a single transaction with prepared command reuse. Auto-generates IDs and rolls back atomically on failure.store.BatchUpsert(items), store.BatchUpdate(items), and store.BatchRemove<T>(ids) apply many writes as one set operation — a single multi-row INSERT … ON CONFLICT deep-merge on SQLite/DuckDB, one BulkWrite/DeleteMany on MongoDB, parallel request waves on Cosmos, and a single DELETE … IN (…) for BatchRemove on every relational provider. All-or-nothing: the first version conflict rolls the whole batch back.WithinRadius, WithinBoundingBox, NearestNeighbors) and full OGC geometry. Map a GeoPoint? or a Geometry? property (GeoLineString, GeoPolygon with holes, GeoMultiPoint/GeoMultiLineString/GeoMultiPolygon, GeoGeometryCollection; GeoJSON-serialized) with cfg.MapSpatialProperty(x => x.Area), then query with the Geo-prefixed topological predicate family — GeoIntersects, GeoContainedBy, GeoContains, GeoDisjoint, GeoTouches, GeoCrosses, GeoOverlaps, GeoEquals, GeoCovers, GeoCoveredBy — plus GeoWithinDistance(geometry, meters). Every predicate takes an optional orderByDistanceFrom (point or geometry) and returns SpatialResult<T> with DistanceMeters; NearestNeighbors works over geometry too. The Geometry model also exposes in-memory Area/Length/Centroid/IsValid/MakeValid (C# accessors — to filter by a measurement server-side, compute the scalar in your app and store it as a normal indexed field). SQLite (R*Tree) and PostgreSQL / MySQL / SQL Server / Oracle / DuckDB (envelope-sidecar; PostgreSQL provisions PostGIS and DuckDB loads spatial unless you set PortableSpatial = true) run the full family via a bbox prune + in-process relate; CosmosDB pushes ST_INTERSECTS/ST_WITHIN/ST_DISTANCE down; MongoDB uses a 2dsphere index ($geoIntersects/$geoWithin/$near). The mapped property can be nullable — documents with a null location are left out of the spatial index (no exception on write, and clearing a location on update purges its stale entry). One geometry is mapped per type, and it is the only one spatial queries can name: a second Geometry? property stores and round-trips fine, but naming it in a Geo* call, a DocumentFunctions predicate or the string grammar throws (most providers answer from the sidecar's geometry column, which holds only the mapped property). Several shapes that are one semantic slot belong in a single GeoMultiPolygon/GeoGeometryCollection, whose union envelope is the right index key.Shiny.Geofencing.Regions + Shiny.Geofencing.Regions.DocumentDb (SQLite), which also carries the US/Canada reference regions formerly in Shiny.DocumentDb.Geo. See Region Geofencing.cfg.MapVectorProperty(x => x.Embedding, dimensions: 1536) + store.Query<T>().NearestVectors(queryEmbedding, k: 10) for cross-provider ANN over ReadOnlyMemory<float> embeddings. NearestVectors<T> is also on IDocumentSession (consistent-snapshot search inside a transaction). Provider-native indexes: pgvector (PostgreSQL), VECTOR_DISTANCE (SQL Server 2025 and Oracle 23ai), DiskANN (CosmosDB), $vectorSearch (MongoDB Atlas), vss (DuckDB), sqlite-vec (SQLite). Cosine / Euclidean / DotProduct everywhere; Hamming on pgvector. Pre-filter via Where(...) where the engine supports it. Auto-embed text properties on insert/upsert via Shiny.DocumentDb.Extensions.AI's AutoEmbedOnInsert<T> — now a write interceptor, so it runs on every provider, inside the write transaction, and resolves Microsoft.Extensions.AI.IEmbeddingGenerator per-write from the DI scope (a fixed-instance overload covers the container-free new DocumentStore(options) path). For Microsoft.Extensions.VectorData interop see the connector bullet below.cfg.MapFullTextProperty(a => a.Body) (or [a => a.Title, a => a.Body]) + store.FullTextSearch<T>("orleans persistence") for relevance-ranked text search, returning FullTextResult<T> (Document + normalized Score, higher = better) ordered by relevance, with an optional pre-filter predicate and a fluent store.Query<T>().Where(...).FullTextMatch("...") form. The native index is created for you and engine-maintained: FTS5 (SQLite), tsvector+GIN (PostgreSQL), FULLTEXT (MySQL), Oracle Text (Oracle), Full-Text Index (SQL Server), the fts extension (DuckDB), full-text policy (CosmosDB), $text (MongoDB), and an in-memory TF-IDF fallback on LiteDB / IndexedDB. Declarative: a type must be mapped before it can be searched. Oracle Text and SQL Server Full-Text Search are optional server components; CosmosDB full-text needs Microsoft.Azure.Cosmos 3.61.0+. For composable full-text, DocumentFunctions.LuceneMatch(a.Body, "orleans AND grain NOT deprecated") works inside a Where (and DocumentFunctions.LuceneScore(...) inside an OrderBy), translating a Lucene query — terms, phrases, AND/OR/NOT, grouping, foo*, foo~, "a b"~5, foo^2 — to the provider's native engine over the same index (SQLite, PostgreSQL, MySQL, SQL Server, Oracle match-only, and full-grammar in-memory on LiteDB/IndexedDB; unsupported operators throw). Also available in the string grammar as lucenematch(...) / lucenescore(...).cfg.MapComputedProperty<decimal>(o => o.Total, o => o.Quantity * o.UnitPrice) maps a value derived from other fields that you filter, sort, and project by exactly like a stored property — though it's never written into the document JSON. Expose it as a [JsonIgnore] property; 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. Default alias mode inlines the definition into each query (zero schema change, every relational provider); indexed: true materializes a native generated/computed column + index on the relational providers (VIRTUAL on SQLite/MySQL, STORED on PostgreSQL, PERSISTED on SQL Server, virtual on Oracle; DuckDB uses alias mode) so filters/sorts are index-served. The value is recomputed and written back onto the object on read. LiteDB/IndexedDB evaluate it in memory (full surface); MongoDB/Cosmos support read-back + projection (not server-side filter/sort by a computed property). Definitions cover JSON field access, string concat, the scalar functions, and numeric arithmetic (+ - * /, also now usable in ordinary Where clauses). Fully AOT/trim-safe — tree-walked to SQL, interpreted for read-back, never compiled.db.client.operation.duration + an operations counter + a returned-rows histogram, tagged per the OTel DB semantic conventions) and an ActivitySource client span per operation directly — no decorator, no opt-in. An IDocumentSession adds a <system>.unit_of_work parent span (tagged db.session.id) so its operations nest into one correlated trace, plus a db.client.unit_of_work.operations histogram (buffered writes flushed per SaveChanges). Covers CRUD, the fluent-query terminals, and the temporal ITemporalDocumentStore ops. Built on System.Diagnostics; subscribe with .AddSource("Shiny.DocumentDb") / .AddMeter("Shiny.DocumentDb"). Zero-cost when nobody is listening; never records document bodies or ids.store.Backup("/path/to/backup.db") copies the database to a file. Available on SqliteDocumentStore, SqlCipherDocumentStore, and LiteDbDocumentStore (not on the IDocumentStore interface).IDocumentBackup) — a separate store capability (probe with store is IDocumentBackup, like IDocumentMaintenance) for moving a whole store in and out. ExportAsync(stream) writes a backup document (a JSON array of { id, docType, data, createdAt, updatedAt } records, body emitted as-is); RestoreAsync(stream) streams it back in with a forward-only reader (never fully buffered) and preserves the original CreatedAt/UpdatedAt across every provider (backward-compatible with older v1 backups that carry no timestamps — those re-stamp now); BulkImportAsync(IAsyncEnumerable<RawDocument>) is the lower-level primitive over raw UTF-8 JSON rows that RestoreAsync adapts. Bodies are bound verbatim — no <T>, no JsonTypeInfo, no reflection over the documents (AOT-friendly). BulkWriteMode chooses collision handling — Insert (fail on duplicate; fastest), Replace (overwrite wholesale), Merge (RFC 7396 deep-merge), SkipExisting — with ChunkSize, per-chunk-vs-single transaction, ClearExistingFirst, and IProgress<BulkProgress>. It is a raw restore lane: the import path skips versioning/CAS, temporal history, interceptors, tenant scoping, and global query filters (not a replacement for BatchUpsert). Implemented on the relational DocumentStore (all SQL providers), MongoDB, and Cosmos DB. Insert works everywhere; Replace/SkipExisting on all relational providers + Mongo/Cosmos; Merge only on SQLite/DuckDB/Mongo/Cosmos (throws elsewhere — use Replace). Native bulk-copy fast path (10-100×) for Insert on PostgreSQL (COPY), SQL Server (SqlBulkCopy), and DuckDB (appender). Mongo/Cosmos imports are best-effort (not atomic); Cosmos export covers the whole database.IDocumentMaintenance.ClearAll) — ((IDocumentMaintenance)store).ClearAll() wipes every document type (plus temporal-history, spatial, and vector sidecars) for test/dev resets. A whole-store wipe — not type- or tenant-scoped (use Clear<T>() for one type) — that targets only user tables in the current database and never the system catalogs. Implemented on the relational DocumentStore (SQLite, SQL Server, PostgreSQL, MySQL, DuckDB, Oracle), MongoDB, and CosmosDB; SqliteDocumentStore.ClearAllAsync() still works and delegates to it.AddDocumentStore(configure, multiTenant: true)) adds a TenantId column and filters every read and write, and tenant-per-database (AddMultiTenantDocumentStore(factory)) gives each tenant its own store. Both identify the caller through a user-implemented ITenantResolver (register it scoped and it resolves from the request's own DI scope), and consumer code is unchanged either way. Tenant-per-database takes an options factory (relational — the store is built for you and tagged db.namespace = tenant) or a built store, so tenants can live on any provider. Stores are built on first touch and held in a bounded cache — MaxCachedStores LRU + IdleTimeout, with a lease per DI scope so eviction never closes a store a request is still using — and TenantStoreOptions.SeedFromRegisteredSeeders() runs your registered seeders per tenant (versioned run-once, per tenant database) instead of once at startup. ITenantStoreManager adds ActiveTenants / WarmAsync / EvictAsync for onboarding and offboarding without a restart.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 is versioned via a DocumentSeedMarker (bump Version to re-run). Wire with AddDocumentSeeder<T>() / AddDocumentSeeder(name, version, delegate) (runs at host startup via a hosted service) or call DocumentSeedRunner.RunAsync(store, seeders) directly (e.g. on MAUI).o.AddOutbox() + services.AddDocumentOutbox<TDispatcher>() records a domain event in the same transaction as the write that caused it, then delivers it in the background. session.Add(order).Enqueue(new OrderPlaced(...)) commits both rows together or neither, and cfg.PublishToOutbox(ctx => ...) does it declaratively per type. 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 (alert on the age of the oldest pending message, not the depth), and the traceparent captured at enqueue is restored at dispatch. Requires a store whose unit of work is a real transaction — relational + LiteDB; every other backend compensates rather than commits, so a new IDocumentStore.SupportsTransactions capability fails the host start by name instead of shipping a dual write dressed as an outbox.cfg.MapProperty(x => x.Ssn, p => p.Encrypt()) stores the property as AES-256-GCM ciphertext (enc:1:<keyId>:<base64>) on every provider and returns plaintext on read. Installed as a JsonTypeInfo modifier rather than an interceptor, so every write path — including temporal history and backup export — is covered by construction and no provider knows about it. EncryptionMode.Deterministic keeps equality filters working by rewriting the predicate's constant into ciphertext (and leaks equality/frequency — opt in per property); anything that cannot be answered against ciphertext throws with an explanation instead of matching nothing. Key rotation is a key ring plus store.RewrapAsync<T>(), values written before the mapping keep reading, and it is AOT-clean. In the core package — AES-GCM is in the BCL, so there is nothing extra to install. Encryption is at rest: a materialized document holds the value, so Get/ToList, OData responses and the AI tool results all return plaintext — mapping a property does not keep it out of an HTTP response (project to a DTO for that). Because the converters are symmetric, serializing a materialized document back through the store's own options would re-encrypt it; DocumentEncryption.PlaintextView(options) is the writer for that, and returns the same instance when nothing is encrypted. DocumentEncryptionFormat is the public read-only contract for the envelope itself — TryParse gives you the version and key id without any key — so a backup checker, a repair job or the Admin UI can tell an envelope from plaintext instead of hand-rolling a StartsWith("enc:") test that mis-classifies an ordinary value.Shiny.DocumentDb.JsonSchema) — attach a JSON Schema (draft 2020-12, via JsonSchema.Net) to a document type and the store validates the exact JSON about to be persisted just before the write. No DI required — options.ConfigureDocument<Customer>(cfg => cfg.MapJsonSchema(schemaJson)) needs no container (repeated calls accumulate into one interceptor), so it works with a hand-built new DocumentStore(options); the DI form is services.AddDocumentJsonSchema(o => o.MapJsonSchema<Customer>(schemaJson)). A failure throws DocumentSchemaValidationException with field-level errors and rolls the write back. Map schemas by JsonSchema object, JSON text, Stream, or cfg.MapJsonSchemaFromFile(path). Enforces what the C# type can't — maxLength, ranges, pattern, enum, additionalProperties:false, reference-type required-ness — and asserts format (email/uuid/date-time) by default. Schema names match the serialized (camelCase) JSON names. Runs on BeforeWrite, so it composes with Shiny.DocumentDb.AppDataSync for free (invalid documents never reach the outbox); suppressed writes (inbound sync, bulk import) skip validation by design.Shiny.DocumentDb.AppDataSync) — make the store the local cache of an offline-first app that bidirectionally syncs to an HTTP backend 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: every local Insert/Update/Upsert/Remove is auto-enqueued to the sync outbox, and every pulled server change is auto-applied 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. Set-based writes (ExecuteUpdate/ExecuteDelete/Clear<T>) throw SyncBulkWriteNotSupportedException on synced types (use ClearAll for a whole-store reset); batch writes enqueue each item. Synced types implement Shiny.Data.Sync.ISyncEntity; the store and sync serializers are validated to share one JSON contract at startup. Client-tier providers (SQLite, LiteDB, IndexedDB).Shiny.DocumentDb.OData + Shiny.DocumentDb.AspNetCore.OData) — expose a document type as an OData v4 entity set: $filter/$orderby/$top/$skip/$count/$select are translated onto the fluent IDocumentQuery<T> and run against any provider. The translator engine is dependency-free and AOT-clean; the ASP.NET Core host adds EDM + endpoint wiring (JIT-only). Global query filters always apply underneath, so a client can't $filter its way past them. Per-entity-set governance (ODataQueryPolicy) locks down a public endpoint: default/max page size, allowed system options, per-property filter/sort/select allowlists, and filter-complexity limits (a violation → 400).Shiny.DocumentDb.Aspire.Hosting / .Client / .Orleans) — make the backend a deployment decision: builder.AddPostgresDocumentStore("orders").WithSeeder(...) in the AppHost picks the provider (Postgres/SQL Server/MySQL/SQLite) and gates seeding to two triggers — the first time that database is set up, or a destructive recreation (DocumentStoreSeedMode.Recreate), with the "already seeded" marker kept inside the database so wiping the data re-arms it; the consuming service calls builder.AddDocumentStore("orders") to get the keyed store wired with health checks + OpenTelemetry. The client opens up to DI-aware setup — configureServiceOptions: (sp, o) => … configures options with the resolved IServiceProvider, and a MultiTenant settings flag registers a shared-table multi-tenant store from a registered ITenantResolver in one line. A source-generated typed DocumentContext can be backed by an Aspire resource too — builder.Services.AddOrdersContext(builder.AddDocumentContextProvider("orders")). .WithAi(provider, model, key) on the admin resources configures the admin tool's AI assistant from the AppHost (read-only in the tool once the host owns it; WithAiWrites is a separate, deliberate grant), and the terminal tool takes the same as --ai-provider/--ai-model flags. silo.UseAspireDocumentDb("orders") backs Orleans grain storage/reminders/clustering/directory with the same Aspire-provisioned store, and DocumentDbOrleansFeatures.Streams adds the persistent stream provider (opt-in, since streams need a backend with row-level locking). Server-tier only.ShinyDocDbMyAdmin, ShinyDocDbMyAdmin.Tui) — a phpMyAdmin-style front end for the relational stores, as a container image (ghcr.io/shinyorg/shiny-docdb-myadmin, mirrored to aritchie/shiny-docdb-myadmin on Docker Hub), as a Docker Desktop extension (docker extension install aritchie/shiny-docdb-myadmin-extension) that starts it and wires up the database containers already running on your machine, and as a terminal app (dotnet tool install -g ShinyDocDbMyAdmin.Tui, then shinydocdb) that share one admin layer, one connection store and one bundle format. Browse and edit documents in a paged, filterable grid, run SQL in the target dialect with bound parameters, inspect the inferred shape of a type and create/drop JSON property indexes, import/export JSON/NDJSON/CSV, view GeoJSON geometry on a rendered map or blob sidecar payloads with preview and download, read field-level-encryption envelopes and report key coverage across a type without holding any key (and refuse to overwrite one with clear text), triage the outbox queue and dead letters, and ask an AI assistant about the data — read-only by default, with per-connection opt-ins to allow the assistant to insert_document, update_document and delete_document on that connection (OpenAI / Azure OpenAI / Anthropic / any OpenAI-compatible endpoint, configured per connection and removable outright for demo deployments). It drops into an AppHost in one line — builder.AddDocumentDbAdmin(port: 8085).WithReference(store) — and every store you reference shows up already connected. On Aspire 13.5+ the terminal front end models the same way and needs no container: builder.AddDocumentDbAdminTerminal().WithReference(store).WithStartupProfile(store), attached to with aspire terminal attach documentdb-terminal. It reaches a file-backed store without a bind mount, and it is dev-loop only — terminal sessions don't deploy, so anything you publish still wants the container. See Admin UI.Microsoft.Extensions.VectorData connector — Shiny.DocumentDb.Extensions.VectorData puts MEVD's VectorStore / VectorStoreCollection<TKey, TRecord> face on any vector-capable provider, so the store drops into MEAI, the Microsoft Agent Framework, or Semantic Kernel wherever they expect that abstraction. Decorate the record with [VectorStoreKey]/[VectorStoreVector], call o.MapVectorRecord<Note>() while configuring the options (which emits the equivalent cfg.MapVectorProperty), and services.AddDocumentDbVectorStore(...) registers one store behind both IDocumentStore and VectorStore — a single connection pool. Every other MEVD connector is single-store; this one runs the same record model over many backends (SQLite for dev/mobile, PostgreSQL/pgvector or SQL Server for prod, Cosmos/Atlas/Redis), swapped by configuration. MEVD's filter is already an Expression<Func<T, bool>>, so it reaches NearestVectors untouched and is still pushed into the ANN search where the provider supports it. Text queries embed through an IEmbeddingGenerator from the definition, the constructor, or DI. Vector-capable providers only — the rest throw NotSupportedException at construction; dynamic (Dictionary<string, object?>) collections and multi-vector records are not supported.Shiny.DocumentDb.Extensions.AI exposes IDocumentStore operations as Microsoft.Extensions.AI tool functions for LLM agents. Register document types with per-type capability flags (ReadOnly, All, or individual operations), structured filter expressions with boolean combinators, field visibility control (AllowProperties/IgnoreProperties), non-removable row-level scope filters (Where(...)) the LLM cannot see or bypass — enforced on every tool including get/insert/update/delete — and page size caps. Request-resolved scopes (13.0) — .Where<ITenantContext>((tenant, _) => o => o.TenantId == tenant.TenantId) (plus raw-context and async forms) resolve the predicate per tool call from AIFunctionArguments.Services, so a shared server scopes per caller rather than per registration; it fails closed (a throwing filter, an unresolvable service or a call with no services fails the tool call rather than running unscoped) and a missing Where<TService> registration is a startup error. No DI required — call store.CreateAITools(b => b.AddType(...)) on a hand-built IDocumentStore to get a DocumentStoreAITools directly; the DI form (services.AddDocumentStoreAITools(...) → resolve DocumentStoreAITools) builds the same thing from the container. Pass .Tools to any IChatClient.Shiny.DocumentDb.Mcp + the ShinyDocDbMcp dotnet tool) — point Claude Code, Claude Desktop, Copilot or any Model-Context-Protocol 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, one set of tests), wrapped in the MCP envelope and joined by resources (documentdb://types, .../{name}/schema, .../{name}/sample, documentdb://stats — the data-touching ones run through the registered tools, so the scope and page caps apply to a resource read too), two prompts (explain-collection, build-filter), and an audit line per call (tool, caller, argument digest, outcome, duration). Read-only by default with two locks on the destructive path (the per-type capability and a server-wide AllowWrites()), page caps, property hiding, no raw-SQL tool and no schema mutation. The library hosts on Streamable HTTP in any ASP.NET app (AddDocumentDbMcpServer(...).WithHttpTransport() + app.MapDocumentDbMcp("/mcp")); the stdio tool needs no compiled document classes — it discovers what to expose from the stored TypeName discriminators and takes its connections from the admin tool's existing encrypted profile store.Shiny.DocumentDb.AspNetCore) — app.MapDocuments<Order>("/orders", …) turns a document type into a complete HTTP resource: list, by-id, count, create, replace, RFC 7396 merge-patch, delete, and a live Server-Sent-Events tail at /orders/stream. Plain JSON, framework reference only, AOT-clean (routes are mapped as RequestDelegates, so nothing reflects over a handler signature). Filtering uses the store's own string grammar behind a per-endpoint field allowlist (AllowFilterOn) — an unlisted field is a 400, not a table scan — with skip/take (clamped to MaxPageSize), cursor paging, sparse fieldsets (?fields=), ETag/If-Match concurrency (412, or 428 with RequireIfMatch), and ProblemDetails throughout. Scope(...) is the HTTP twin of the AI tools' non-removable Where: resolved per request from the request's own DI scope, AND-ed, unremovable, and an out-of-scope document is 404 rather than 403 on every verb. List reads stream stored bodies straight to the socket through the raw-JSON terminals. MapDocumentCollection does the same for a schema-free JSON collection. The other half of the HTTP story from the OData packages — never map both on one prefix.Shiny.DocumentDb.Hosting) — the three things a package needs to host DocumentDb over a transport, so a host can live in any repo without an InternalsVisibleTo grant here first. DocumentPredicate.Compile<T>(expr) turns a predicate into a delegate without Reflection.Emit — a host has to check a scope against an incoming document on POST/PUT, before there is anything to query against, and Expression.Compile() would forfeit the trimmed/AOT guarantee of the hosting app; it is also the same evaluator the query layer uses, so the pushed-down predicate and the in-memory check cannot disagree about what a scope means. DocumentFilter.Parse<T> / ParseJson lower the string grammar to an expression, so a live tail can apply ?filter= to changes arriving in memory. DocumentStoreAccessor.GetMappings / GetVersionMapping read the store's mapping registry on any provider, which is what an ETag/If-Match implementation needs. The AOT promise is enforced by a Native AOT publish and the signatures by a public-API surface test.Shiny.DocumentDb.Orleans) — a full Microsoft Orleans stack — grain storage (+ PubSubStore), reminders (IReminderTable), cluster membership/clustering (IMembershipTable), and grain directory (IGrainDirectory) — built entirely on IDocumentStore, so one set of implementations runs on every backend. siloBuilder.AddDocumentDbGrainStorage(...) / .AddDocumentDbReminders(...) / .AddDocumentDbClustering(...) / .AddDocumentDbGrainDirectory("Default", ...). The Orleans ETag maps to a version-checked document (atomic CAS → InconsistentStateException on conflict), and grain state is stored as nested, structured JSON — so you can query grain state directly without activating the grains (reporting/dashboards/admin over the persisted read model, something Orleans' point-key storage contract can't do) and opt into cfg.MapTemporal for a free state-history audit trail. Companion packages Shiny.DocumentDb.Orleans.MongoDb / Shiny.DocumentDb.Orleans.CosmosDb wire grain storage for those backends in one call; a StoreFactory escape hatch covers the rest. (Membership needs multi-document transactions — relational or MongoDB replica set, not Cosmos.)siloBuilder.AddDocumentDbStreams("Default", …)) — durable, inspectable Orleans streams on the same store, so a cluster needs no queue service. 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 off the database, 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 uses a per-queue counter row reserved under a row lock inside the enqueue transaction, not a native identity column: identity values are assigned at insert but rows appear at commit, so a late-committing transaction can be stepped over by the receiver's watermark and its event silently never delivered. Backends are gated by the new SupportsPessimisticLocking capability — PostgreSQL, SQL Server, MySQL, MariaDB, Oracle, CockroachDB — and the silo refuses to start on anything else. Durable rewind (IsRewindable): a subscriber can resume from a StreamSequenceToken older than anything in memory because the cache replays the events table — something no queue-backed provider can do — bounded by Retention and served by a second (QueueId, StreamId, Seq) index. IStreamAdmin (keyed by provider name) plus a Streams screen in ShinyDocDbMyAdmin report per-queue depth, lag, retained history and stuck streams; both are read-only, because a stream event is a position in a gap-free sequence rather than a unit of work you can requeue. This is a database-backed queue (thousands of events/sec on PostgreSQL), not an Event Hub replacement.| Simple flat-table CRUD |
jsonTypeInfo{table}_vec_{type}| Encryption | Appears when a type has field-level encrypted properties. Reads the enc:1:<keyId>:<base64> envelope with no key at all: badges the value everywhere it would otherwise be a wall of base64, counts on demand how many values across the whole type sit under each key id (which is what tells you whether a RewrapAsync<T>() finished, and nothing else does — retiring a key early makes those documents unreadable), and reports mode only when the data proves it (a repeated ciphertext proves deterministic; nothing can prove randomized, so it is never claimed). Saving clear text over an envelope throws unless confirmed, because the library reads a non-envelope as pre-encryption plaintext and the failure is a silent loss of protection rather than an error; imports count the same thing and exports say plainly that they carry ciphertext. Query surfaces that cannot work are withdrawn (quick search) or explained (the filter console compiles to SQL, so it never encrypts your constant). Reading the values back is opt-in per connection, masked until revealed, never in an export, never in demo mode, and never in the assistant. |
| Blobs | Appears when the table has a {table}_blobs sidecar. Lists payloads without ever selecting the blob column, previews images and text, and downloads over a plain HTTP endpoint rather than the Blazor circuit. Only allow-listed raster images render inline; everything else is served as an attachment under nosniff + default-src 'none'. |
| Outbox | Appears when the database holds transactional outbox messages. A database-level screen rather than a type tab: how deep the queue is, whether it is draining (oldest pending, which is what separates "busy" from "the processor died"), what is dead-lettered grouped by message type and error, and requeue / purge behind a confirm. It cannot dispatch and says so on the page — delivery is your application's IOutboxDispatcher, over a transport the tool cannot reach; requeue only makes a message eligible again. |
| Import / Export | Stream a type out as JSON, NDJSON, envelope JSON (round-trippable) or CSV; import JSON or NDJSON back with fail / replace / skip handling for duplicate ids. Also generates documents that look like the ones already there — numbers inside the observed range, dates inside the observed span, categorical fields drawn from the set actually used, ids continuing whatever scheme is in place — with a seeded preview that commits byte-identically. |
| Assistant | Appears when a connection has AI configured. A chat that answers questions about the data by composing the same reads the rest of the tool performs, scoped to the type you're looking at or the whole connection. Configured per connection — OpenAI, Azure OpenAI, Anthropic, or any OpenAI-compatible endpoint (OpenRouter, Groq, LM Studio, vLLM, Ollama) — so development can use a hosted model while production uses a local one, or none. It cannot write: the model is given ten tools and every one is a read, so read-only is a property of the tool surface rather than an instruction in a prompt. |
| Query | Two modes. Filter grammar takes DocumentDb's own string query syntax (status == 'Shipped' and total:number > 100) with Where / OrderBy / Project boxes — run through the library's own parser via store.Collection(typeName), not a reimplementation, so the answer matches what your code would get. The compiled SQL is shown beside the results, Explain runs the provider's query plan over it (planned, not executed — safe on a read-only connection), unindexed fields in the query are offered as one-click index creates that then re-run and re-explain, and one button drops the SQL into the other mode. Raw SQL is whatever you type, in the target database's dialect, with @name parameters bound from a JSON box so the types stay honest. |