Shiny.DocumentDb

June 13, 2026 · View on GitHub

NuGet NuGet NuGet NuGet NuGet NuGet NuGet NuGet NuGet NuGet NuGet NuGet NuGet NuGet NuGet

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, DuckDB, IndexedDB (Blazor WASM), MySQL, SQL Server, PostgreSQL, and Oracle.

Documentation

Features

  • Zero schema, zero migrations — store entire object graphs (nested objects, child collections) as JSON documents. No CREATE TABLE, no ALTER TABLE, no JOINs.
  • Multiple database providers — use SQLite for mobile/embedded, LiteDB for file-based NoSQL, CosmosDB or MongoDB for cloud/NoSQL workloads, DuckDB for analytical workloads, IndexedDB for Blazor WebAssembly, or MySQL, SQL Server, PostgreSQL, Oracle for server workloads. Same API, same LINQ expressions, different backend.
  • Fluent query builderstore.Query<User>().Where(u => u.Age > 30).OrderBy(u => u.Name).Paginate(0, 20).ToList() with full LINQ expression support for nested properties, Any(), Count(), string methods, null checks, and captured variables.
  • IAsyncEnumerable<T> streaming — yield results one-at-a-time with .ToAsyncEnumerable() instead of buffering into a list. Eliminates Gen1 GC pressure at scale with comparable throughput.
  • Expression-based JSON indexesstore.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.)
  • SQL-level projections — project into DTOs with json_object at the database level via .Select(). No full document deserialization needed.
  • Full AOT/trimming support — every API has an optional 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.
  • 10-30x faster nested inserts vs sqlite-net — one write per document vs multiple table inserts with foreign keys. 2-10x faster reads on nested data.
  • Mandatory typed Id property — every document type must have a 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.
  • JSON Merge Patch (Upsert)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.
  • Surgical field updatesstore.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.
  • Document diff (JsonPatchDocument)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.
  • Typed Id lookupsGet, Remove, SetProperty, and RemoveProperty accept the Id as object so you can pass a Guid, int, long, or string directly. Unsupported types throw ArgumentException.
  • Paginationstore.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.
  • Dynamic sort columns (AOT-safe)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.
  • Optimistic concurrencyMapVersionProperty<T>(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.
  • Change observation (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 inside RunInTransaction 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.
  • Per-query change monitoring — call .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.
  • Global query filtersoptions.AddQueryFilter<User>(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 soft-delete, row-level security, or "active only" scopes. Insert is intentionally unfiltered (matches Entity Framework Core).
  • Temporal history (system-time versioning)options.MapTemporal<Order>(o => { o.Retention = TimeSpan.FromDays(90); o.MaxVersions = 50; o.CaptureActor = () => userId; }) opts a type into append-only versioning. Every Insert/Update/Upsert/Remove/SetProperty/RemoveProperty/BatchInsert (including inside RunInTransaction) 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.
  • Native change feeds (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.)
  • Concurrent operations on server SQL — a single 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>>).
  • Transactionsstore.RunInTransaction(async tx => { ... }) with automatic commit/rollback. The transaction pins one connection for the entire user callback so every nested op shares it.
  • Batch insertstore.BatchInsert(items) inserts a collection in a single transaction with prepared command reuse. Auto-generates IDs and rolls back atomically on failure.
  • Spatial / geo queriesWithinRadius, WithinBoundingBox, and NearestNeighbors methods with GeoPoint support. SQLite uses R*Tree virtual tables; CosmosDB uses native ST_DISTANCE/ST_WITHIN. Configure with MapSpatialProperty<T>(x => x.Location).
  • Vector / ANN searchMapVectorProperty<T>(x => x.Embedding, dimensions: 1536) + store.Query<T>().NearestVectors(queryEmbedding, k: 10) for cross-provider ANN over ReadOnlyMemory<float> embeddings. 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 via Shiny.DocumentDb.Extensions.AI's AutoEmbedOnInsert<T> hook + Microsoft.Extensions.AI.IEmbeddingGenerator.
  • Telemetry & observability (Shiny.DocumentDb.Diagnostics)services.AddDocumentStoreInstrumentation() wraps any provider in a decorator that emits OpenTelemetry-native metrics (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. Covers CRUD, the fluent-query terminals, the temporal ITemporalDocumentStore ops, and RunInTransaction (inner ops become child spans). Built on System.Diagnostics.Metrics/IMeterFactory; subscribe with .AddMeter("Shiny.DocumentDb") / .AddSource("Shiny.DocumentDb"). Zero-cost when nobody is listening; never records document bodies or ids.
  • Hot backupstore.Backup("/path/to/backup.db") copies the database to a file. Available on SqliteDocumentStore, SqlCipherDocumentStore, and LiteDbDocumentStore (not on the IDocumentStore interface).
  • Clear allSqliteDocumentStore.ClearAllAsync() deletes all documents across all tables in the SQLite database, including spatial sidecar tables.
  • AI tool integrationShiny.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), and page size caps. Resolve DocumentStoreAITools from DI and pass .Tools to any IChatClient.
  • Orleans persistence stack (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 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.)

Comparison with alternatives

Shiny.DocumentDbMicrosoft.Data.Sqlite (raw ADO.NET)sqlite-net-pcl
Schema managementZero — just store objectsYou write every CREATE TABLE, ALTER TABLE, migrationAuto-creates flat tables from POCOs
Database providersSQLite, LiteDB, CosmosDB, MongoDB, DuckDB, IndexedDB, MySQL, SQL Server, PostgreSQL, OracleSQLite onlySQLite only
Nested objects & child collectionsStored and queried as a single JSON documentMust design normalized tables, write JOINs, manage foreign keysNo support — flat columns only, child collections require separate tables + manual joins
LINQ queries on nested datastore.Query<Order>().Where(o => o.Lines.Any(l => l.Price > 10)).ToList()Hand-written json_extract SQLNot possible on nested data
AOT / trimmingFirst-class optional JsonTypeInfo<T> on every APIManual — you control all SQLRelies on reflection; no AOT support
MigrationsNot needed — schema-free JSONYou own every migrationYou own every migration
ProjectionsSQL-level json_object projections via .Select()Manual SQLNot available
Transactionsstore.RunInTransaction(async tx => ...)Manual BeginTransaction + Commit/RollbackRunInTransactionAsync available
JSON property indexesstore.CreateIndexAsync<User>(u => u.Name, ctx.User) — LINQ expression indexes on json_extractManual CREATE INDEX on json_extractColumn indexes only
Best fitObject graphs, nested data, rapid prototyping, settings stores, cachesFull SQL control, complex reporting queries, performance-critical bulk opsSimple flat-table CRUD

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.

Replacing EF Core on .NET MAUI

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.

Why EF Core is a poor fit for MAUI

  • No AOT support. EF Core relies heavily on runtime reflection and dynamic code generation for change tracking, query compilation, and model building. It carries [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.
  • Migrations are friction, not value. On a server you run migrations against a shared database with a known lifecycle. On a mobile device, the database ships inside the app or is created on first launch. EF Core's migration pipeline (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.
  • Heavy dependency graph. EF Core pulls in 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.
  • Relational overhead for non-relational data. Mobile apps typically store user preferences, cached API responses, offline data queues, and local state. This data is naturally document-shaped (nested objects, variable structure). Forcing it into normalized tables with foreign keys and JOINs adds accidental complexity.

Why this library fits

ConcernEF CoreShiny.DocumentDb
AOT / trimmingReflection-heavy; no AOT supportEvery API has optional JsonTypeInfo<T>; zero reflection required
Database supportMany providersSQLite, LiteDB, CosmosDB, MongoDB, DuckDB, IndexedDB, MySQL, SQL Server, PostgreSQL, Oracle
MigrationsRequired for every schema changeNot needed — schema-free JSON storage
Nested objectsNormalized tables, foreign keys, JOINsSingle document, single write, single read
App bundle sizeLarge dependency treeCore package + one provider dependency
Startup timeDbContext model building, migration checksOpen connection and go
Offline / sync patternsComplex change trackingStore and retrieve document snapshots directly

AOT and trimming on mobile platforms

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:

  • Source-generated JSON serialization. The 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.
  • No runtime expression compilation. LINQ expressions are translated to SQL strings by a visitor — no Expression.Compile(), no Reflection.Emit, no dynamic delegates.
  • No model building. There is no equivalent of EF Core's OnModelCreating that discovers entity types and relationships through reflection at startup.

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.

Benchmarks

Measured with BenchmarkDotNet v0.15.8 on Apple M2, .NET 10.0.3, macOS. Full source in benchmarks/.

Flat POCO (single table)

Insert

MethodCountMean
DocumentStore Insert10572 us
sqlite-net Insert103.02 ms
DocumentStore Insert1005.24 ms
sqlite-net Insert10026.36 ms
DocumentStore Insert100052.52 ms
sqlite-net Insert1000260.29 ms

Batch insert

MethodCountMeanAllocated
DocumentStore BatchInsert10218 us14.02 KB
sqlite-net InsertAllAsync10412 us5.16 KB
DocumentStore BatchInsert100585 us117.14 KB
sqlite-net InsertAllAsync100735 us44.92 KB
DocumentStore BatchInsert10006,399 us1,104 KB
sqlite-net InsertAllAsync10003,072 us439 KB

BatchInsert is ~2x faster at small-to-medium batch sizes (10–100 items) thanks to prepared command reuse in a single transaction. At 1000 items, sqlite-net's simpler row structure and lower serialization overhead give it the edge.

Get by ID

MethodMeanAllocated
DocumentStore GetById3.75 us1.99 KB
sqlite-net GetById16.18 us3.73 KB

Get all

MethodCountMeanAllocated
DocumentStore GetAll10046.10 us48.47 KB
sqlite-net GetAll10080.27 us28.37 KB
DocumentStore GetAll1000437.16 us470.35 KB
sqlite-net GetAll1000464.82 us246.35 KB

Query (filter by name, 1000 records)

MethodMeanAllocated
DocumentStore Query269.75 us4.86 KB
sqlite-net Query59.20 us5.33 KB

sqlite-net is faster for simple indexed-column queries because it queries column values directly, while the document store must use json_extract. The document store shines with nested data (see below).

Nested objects with child collections (Order + Address + OrderLines + Tags)

This is where the document store architecture pays off. sqlite-net requires 3 tables, 6 inserts per order, and 3 queries per read with manual rehydration.

Insert (nested)

MethodCountMean
DocumentStore Insert (nested)10686 us
sqlite-net Insert (3 tables)1017.26 ms
DocumentStore Insert (nested)1005.69 ms
sqlite-net Insert (3 tables)100176.48 ms
DocumentStore Insert (nested)100055.62 ms
sqlite-net Insert (3 tables)10002.58 s

Get by ID (nested)

MethodMeanAllocated
DocumentStore GetById (nested)5.04 us3.88 KB
sqlite-net GetById (3 queries)48.26 us16.05 KB

Get all (nested)

MethodCountMeanAllocated
DocumentStore GetAll (nested)100148 us237 KB
sqlite-net GetAll (3 tables + rehydrate)100326 us159 KB
DocumentStore GetAll (nested)10001.67 ms2,353 KB
sqlite-net GetAll (3 tables + rehydrate)10002.75 ms1,438 KB

Query (nested, filter by status)

MethodMeanAllocated
DocumentStore Query (nested, by status)1.45 ms1,180 KB
sqlite-net Query (3 tables + rehydrate)2.27 ms1,013 KB

For nested data, the document store is 10-30x faster on inserts and 2-10x faster on reads because it stores/retrieves the entire object graph in a single operation vs. multiple table writes and JOINs.

Index impact

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.

Flat POCO query (filter by name, 1000 records)

MethodMeanAllocated
Query without index270 us4.71 KB
Query with index8.52 us4.71 KB

~32x faster — the indexed query resolves in microseconds because SQLite uses the partial index directly.

Nested query (filter by ShippingAddress.City, 1000 records, ~200 matches)

MethodMeanAllocated
Nested query without index992 us473 KB
Nested query with index326 us473 KB

~3x 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 (IAsyncEnumerable) vs buffered

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.

Flat POCO

MethodCountMeanGen1Allocated
ToList (buffered)10046.26 us0.4948.47 KB
ToAsyncEnumerable (streaming)10047.07 us46.35 KB
ToList (buffered)1000439.63 us21.00470.35 KB
ToAsyncEnumerable (streaming)1000456.58 us454.16 KB

Nested objects

MethodCountMeanGen1Allocated
ToList nested (buffered)100147.80 us6.84236.67 KB
ToAsyncEnumerable nested (streaming)100150.50 us0.24234.55 KB
ToList nested (buffered)10001.62 ms134.772,353 KB
ToAsyncEnumerable nested (streaming)10001.43 ms1.952,337 KB

Nested query (filter by status, ~500 matches from 1000)

MethodMeanGen1Allocated
Query Where ToList (buffered)1.41 ms70.311,180 KB
Query Where ToAsyncEnumerable (streaming)1.39 ms1,172 KB

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.

Installation

Install the core package plus the provider for your database:

# SQLite (mobile, embedded, local)
dotnet add package Shiny.DocumentDb.Sqlite

# 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

For dependency injection, install the DI extensions package:

dotnet add package Shiny.DocumentDb.Extensions.DependencyInjection

Setup

Direct instantiation

// 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: SqliteDocumentStore and SqlCipherDocumentStore are still available as convenience wrappers that extend DocumentStore. They accept a connection string directly: new SqliteDocumentStore("Data Source=mydata.db") or new SqlCipherDocumentStore("encrypted.db", "mySecretKey").

Options reference

PropertyTypeDefaultDescription
DatabaseProviderIDatabaseProvider(required)The database provider to use (e.g., SqliteDatabaseProvider, MySqlDatabaseProvider, SqlServerDatabaseProvider, PostgreSqlDatabaseProvider, OracleDatabaseProvider, DuckDbDatabaseProvider). LiteDB, CosmosDB, and MongoDB use their own options classes instead.
TypeNameResolutionTypeNameResolutionShortNameHow type names are stored — ShortName (e.g. User) or FullName (e.g. MyApp.Models.User)
JsonSerializerOptionsJsonSerializerOptions?nullJSON serialization settings. When a JsonSerializerContext is attached as the TypeInfoResolver, all methods auto-resolve type info from the context
UseReflectionFallbackbooltrueWhen false, throws InvalidOperationException if a type can't be resolved from the configured TypeInfoResolver instead of falling back to reflection. Recommended for AOT deployments
TableNamestring"documents"Name of the default shared document table. Types not explicitly mapped via MapTypeToTable<T>() are stored here
LoggingAction<string>?nullCallback invoked with every SQL statement executed

Dependency injection

Install the Shiny.DocumentDb.Extensions.DependencyInjection package and use AddDocumentStore to register 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
    };
});

Named stores (multiple databases)

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 IDocumentStoreProvider
public class MyService(IDocumentStoreProvider stores)
{
    void DoWork() => stores.GetStore("users").Insert(...);
}

Table-Per-Type Mapping

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.

Basic mapping

var store = new DocumentStore(new DocumentStoreOptions
{
    DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
}.MapTypeToTable<User>()            // auto-derived table name → "User"
 .MapTypeToTable<Order>("orders")   // explicit table name
);

// Users → "User" table, Orders → "orders" table, everything else → "documents"

Custom Id property

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 MapTypeToTable, or used on their own with MapIdProperty to keep the type in the default shared table.

var store = new DocumentStore(new DocumentStoreOptions
{
    DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
}
// Dedicated table + custom Id
.MapTypeToTable<Customer>("customers", c => c.CustomerId)
.MapTypeToTable<Sensor>("sensors", s => s.DeviceKey)
// Default shared table + custom Id
.MapIdProperty<BlogPost>(p => p.Slug)
);

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.

Custom Id types

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.)

API reference

OverloadDescription
MapTypeToTable<T>()Auto-derive table name, default Id property
MapTypeToTable<T>(tableName)Explicit table name, default Id property
MapTypeToTable<T>(idProperty)Auto-derive table name, custom Id property
MapTypeToTable<T>(tableName, idProperty)Explicit table name, custom Id property
MapIdProperty<T>(idProperty)Custom Id property only — type stays in the default shared table
MapIdProperty<T>(propertyName)AOT-safe string overload of the above
  • Fluent — all overloads return DocumentStoreOptions for chaining
  • Duplicate protection — mapping two types to the same table throws ArgumentException
  • AOT-safe — type and property names are resolved at registration time, not at runtime
  • Id remapping is independent of table mapping — use MapIdProperty to override the Id without dedicating a table, or MapTypeToTable(idProperty) to do both at once
  • Tables are lazily created on first use with the same schema and composite primary key

AOT Setup

For 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.

Optional JsonTypeInfo parameters

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.

Setup

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
});

Multiple JSON contexts

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
});

Before vs after

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, and RemoveProperty accept the Id as object — you can pass a Guid, int, long, or string directly. Passing an unsupported type throws ArgumentException. | 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) |

Example

// 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);

How it works

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.

Reflection fallback behavior

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.

Document Types

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; }
}

Auto-generation rules

Id CLR TypeDefault ValueAuto-Gen Strategy
GuidGuid.EmptyGuid.NewGuid()
stringnull or ""Guid.NewGuid().ToString("N")
int0MAX(CAST(Id AS INTEGER)) + 1 per TypeName
long0MAX(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.

Basic CRUD Operations

Insert a document (auto-generated ID)

var user = new User { Name = "Alice", Age = 25 };
await store.Insert(user);
// user.Id is now populated

Insert a document (explicit ID)

await store.Insert(new User { Id = "user-1", Name = "Alice", Age = 25 });
// Throws if "user-1" already exists

Batch insert

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

// Inside a transaction — uses the existing transaction (no nesting)
await store.RunInTransaction(async tx =>
{
    await tx.BatchInsert(moreUsers);
    await tx.Insert(singleUser);
    // All committed or rolled back together
});

Update a document (full replacement)

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 with JSON Merge Patch

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 = "alice@test.com" });

// 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 == "alice@test.com" (preserved)

How it works:

  • On insert (new ID): the patch is stored as the full document.
  • On conflict (existing ID): json_patch(existing, patch) deep-merges the patch into the stored JSON. Objects are recursively merged; scalars and arrays are replaced.
  • Null properties are excluded from the patch automatically. In C#, unset nullable properties (e.g. 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 null and excluded from the merge. Non-nullable properties with default initializers (e.g. string Name = "") will always be included in the patch.

Update a single property (SetProperty)

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, "newemail@test.com");

// 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).

Remove a single property (RemoveProperty)

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.

SetProperty vs RemoveProperty vs Upsert vs Insert vs Update

OperationUse whenScopeCollections
SetPropertyChanging one scalar fieldSingle field, in-place json_setScalar values only
RemovePropertyStripping a field from the documentSingle field, in-place json_removeWorks on any property type
UpsertPatching multiple fields at onceDeep merge via json_patchReplaces arrays entirely (RFC 7396)
InsertAdding a new documentFull document write; throws if Id existsFull control
UpdateReplacing an existing documentFull replacement; throws if not foundFull control
GetDiffDiffing local changes vs stored stateRead-only; returns RFC 6902 patchDeep nested diff; arrays replaced as whole

Get a document by ID

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);

Diff against stored document (GetDiff)

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.

Remove a document

bool deleted = await store.Remove<User>("user-1");
bool removed = await store.Remove<GuidIdModel>(myGuid);

Clear all documents of a type

int deletedCount = await store.Clear<User>();

Optimistic Concurrency (Row Versioning)

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).

Configuration

// Expression-based (reflection)
var store = new DocumentStore(new DocumentStoreOptions
{
    DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
}.MapVersionProperty<Order>(o => o.RowVersion));

// AOT-safe overload with explicit getter/setter
var store = new DocumentStore(new DocumentStoreOptions
{
    DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
}.MapVersionProperty<Order>("RowVersion", o => o.RowVersion, (o, v) => o.RowVersion = v));

All provider options classes support MapVersionProperty: DocumentStoreOptions, LiteDbDocumentStoreOptions, CosmosDbDocumentStoreOptions, and IndexedDbDocumentStoreOptions.

How it works

OperationBehavior
InsertVersion is set to 1 before serialization
UpdateReads the expected version from the object, checks it against the stored version, then increments. Throws ConcurrencyException on mismatch
UpsertInsert path sets version to 1. Update path checks and increments (only when the existing version > 0)
BatchInsertVersion is set to 1 for each document

Example

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

ConcurrencyException

Thrown when a version mismatch is detected. Provides diagnostic properties:

PropertyTypeDescription
TypeNamestringThe document type name
DocumentIdstringThe document Id
ExpectedVersionintThe version the caller expected
ActualVersionint?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}");
}

Fluent Query Builder

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.

Builder methods (non-executing)

MethodDescription
.Where(predicate)Filter by LINQ expression. Multiple calls combine with AND.
.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.
.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.

Terminal methods (execute SQL)

MethodReturnsDescription
.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.

Get all documents of a type

var users = await store.Query<User>().ToList();

Expression-based queries

The preferred way to query. Property names are resolved from JsonTypeInfo metadata, so [JsonPropertyName] attributes and naming policies are respected automatically.

Equality and comparisons

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();

Logical operators

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();

Null checks

var noEmail = await store.Query<User>().Where(u => u.Email == null).ToList();
var hasEmail = await store.Query<User>().Where(u => u.Email != null).ToList();

String methods

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();

Nested object properties

var results = await store.Query<Order>().Where(o => o.ShippingAddress.City == "Portland").ToList();

Collection queries with Any()

// 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();

Collection queries with Count()

// 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 queries

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();

Captured variables

var targetName = "Alice";
var results = await store.Query<User>().Where(u => u.Name == targetName).ToList();

Counting with expressions

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();

Bulk delete with ExecuteDelete

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();

Bulk update with ExecuteUpdate

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);

Ordering

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

Sort by property name (string, AOT-safe)

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.

// Sort by CLR property name
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();

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.

Pagination

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 call

For 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.
  • Any prior .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.

Projections

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.

Flat projection

var results = await store.Query<User>()
    .Where(u => u.Age == 25)
    .Select(u => new UserSummary { Name = u.Name, Email = u.Email })
    .ToList();

Nested source properties

var results = await store.Query<Order>()
    .Where(o => o.Status == "Shipped")
    .Select(o => new OrderSummary { Customer = o.CustomerName, City = o.ShippingAddress.City })
    .ToList();

All documents with projection

var results = await store.Query<Order>()
    .Select(o => new OrderDetail { Customer = o.CustomerName, LineCount = o.Lines.Count() })
    .ToList();

Collection methods in projections

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.

Scalar aggregates

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);

Aggregate projections (GROUP BY)

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();

Streaming queries

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.

Raw SQL queries

For advanced queries not covered by expressions, use raw SQL with provider-specific JSON functions. The SQL syntax varies by provider:

ProviderJSON extract syntax
SQLitejson_extract(Data, '$.name')
MySQLJSON_EXTRACT(Data, '$.name')
SQL ServerJSON_VALUE(Data, '$.name')
PostgreSQL"Data"::jsonb->>'name'
OracleJSON_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);
}

Dynamic query building

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.

Transactions

await store.RunInTransaction(async tx =>
{
    await tx.Insert(new User { Id = "u1", Name = "Alice", Age = 25 });
    await tx.Insert(new User { Id = "u2", Name = "Bob", Age = 30 });
    // Commits on success, rolls back on exception
});

Change Monitoring

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.

Subscribing to all changes for a type

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

Per-document monitoring

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);
}

Per-query monitoring

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.

DocumentChange shape

PropertyDescription
ChangeTypeInserted, Updated, Removed, or Cleared
IdThe document Id (empty for Cleared)
DocumentThe document body. Populated for Inserted / Updated (full-document path); null for Removed, Cleared, and property-level updates

Transactions

Changes performed inside RunInTransaction are buffered and emitted only after the transaction commits. A rollback discards the buffered events:

await store.RunInTransaction(async tx =>
{
    await tx.Insert(new User { Id = "u1", Name = "Alice" });
    await tx.Insert(new User { Id = "u2", Name = "Bob" });
    // Subscribers see nothing yet.
});
// Subscribers receive both Inserted events here, in order.

Unsubscribing

Cancel the CancellationToken passed to NotifyOnChange (or break out of the await foreach). The subscription's channel is unregistered automatically when the iterator exits.

Global Query Filters

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.

Registering filters

var store = new DocumentStore(new DocumentStoreOptions
{
    DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
}
.AddQueryFilter<User>(u => !u.IsDeleted)                        // unnamed
.AddQueryFilter<Order>("tenant", o => o.TenantId == ctx.Current) // named
.AddQueryFilter<Order>("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.

What's filtered

PathFiltered?
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)

Opting out per query

// 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.

Captured variables

Predicates re-translate on each query, so closures pick up the current value:

var ctx = new TenantContext();
options.AddQueryFilter<Order>("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

Caveats

  • Filters require a 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.
  • Spatial sidecar tables are touched by 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.

Native Change Feeds

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:

ProviderMechanism
PostgreSQLLISTEN / NOTIFY with row-level triggers (true push)
SQL ServerChange Tracking, optionally with SqlDependency query notifications (SqlServerChangeFeedOptions)
Cosmos DBNative 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.

Temporal History (System-Time Versioning)

Opt a document type into append-only history with MapTemporal<T>. 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.MapTemporal<Order>(o =>
{
    o.Retention    = TimeSpan.FromDays(90);   // prune expired versions older than this
    o.MaxVersions  = 50;                      // …or cap versions per document
    o.CaptureActor = () => currentUser.Id;    // optional "who" recorded per version
});

Tracked operations: Insert, Update, Upsert, Remove, SetProperty, RemoveProperty, and BatchInsert, including writes inside RunInTransaction (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).

Why the history methods aren't on IDocumentStore

History 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 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 MapTemporal<T> throws InvalidOperationException.

Reading history

// 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);

Fleet-wide queries

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>("alice@corp.com");

// Audit log over a time window (ValidFrom in [from, to))
IReadOnlyList<DocumentVersion<Order>> log = await store.ChangesBetween<Order>(weekStart, weekEnd);

DocumentVersion<T>

PropertyDescription
IdThe document's string Id.
VersionMonotonic version number, starting at 1.
ValidFrom / ValidToThe interval this version was current. ValidTo is null for the version in effect now.
OperationInserted, Updated, or Removed.
ActorThe captured actor, when CaptureActor was configured.
DocumentThe state at this version. null for Removed tombstones.

Retention

Both prune on every write; the current version is never pruned. Set at least one on SQLite/mobile to keep the file bounded.

OptionBehaviour
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 Version so the schema upgrade creates them — bump options.Version when adding MapTemporal to an already-deployed store. A fresh database needs no change.

Telemetry & Observability

Shiny.DocumentDb.Diagnostics adds OpenTelemetry-native metrics and distributed tracing to any provider. It wraps the registered IDocumentStore in a decorator built on the standard .NET primitives (System.Diagnostics.Metrics.Meter via IMeterFactory, and ActivitySource), so 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.

services.AddDocumentStore(o => o.DatabaseProvider = new SqliteDatabaseProvider("Data Source=app.db"));
services.AddDocumentStoreInstrumentation();   // decorate AFTER registering the store

services.AddOpenTelemetry()
    .WithMetrics(m => m.AddMeter("Shiny.DocumentDb"))
    .WithTracing(t => t.AddSource("Shiny.DocumentDb"));

AddDocumentStoreInstrumentation() decorates the non-keyed IDocumentStore registration (preserving its lifetime) and re-points ITemporalDocumentStore at the same instance. The db.system.name tag is derived from the wrapped store, so it works across all eleven providers with no per-provider config.

What it emits

Instrument and tag names follow the OpenTelemetry database client semantic conventions:

InstrumentKindMeaning
db.client.operation.durationHistogram (s)Per-operation latency — the primary signal.
db.client.operationsCounterOperation count.
db.client.response.returned_rowsHistogramDocuments 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), and error.type on failures. Each operation also starts a {system}.{operation} ActivityKind.Client span carrying the same tags, with Error status + exception capture on failure.

Coverage

CRUD, string + fluent-query terminals (ToList/Count/Any/ExecuteDelete/ExecuteUpdate/aggregates), spatial/vector, all ITemporalDocumentStore operations, and RunInTransaction — where the operations inside the callback 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.

Rekeying (SQLCipher only)

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.

Backup (SQLite/SQLCipher/LiteDB only)

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.

  • SQLite (SqliteDocumentStore): Uses the SQLite Online Backup API
  • SQLCipher (SqlCipherDocumentStore): Backup is automatically encrypted with the same password
  • LiteDB (LiteDbDocumentStore): 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");

ClearAllAsync (SQLite only)

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 / Geo Queries

Spatial queries are supported on SQLite (via R*Tree virtual tables) and CosmosDB (via native GeoJSON + ST_DISTANCE/ST_WITHIN). Other providers throw NotSupportedException. Check support at runtime with store.SupportsSpatial.

Spatial types

// 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; }
}

Configuration

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 store = new DocumentStore(new DocumentStoreOptions
{
    DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
}
.MapSpatialProperty<Restaurant>(r => r.Location)
);

For full AOT safety, use the delegate overload:

.MapSpatialProperty<Restaurant>("Location", r => r.Location)

Spatial queries

// 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");

How it works

  • SQLite: Creates RTree sidecar tables that are automatically synced on every insert/update/upsert/remove/clear. Uses bounding box pre-filter via RTree, then Haversine post-filter for exact radius.
  • CosmosDB: 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 store = new DocumentStore(new DocumentStoreOptions
{
    DatabaseProvider = new SqliteDatabaseProvider("Data Source=mydata.db")
    {
        EnableVectorExtension = true   // load sqlite-vec on every connection
    }
}.MapVectorProperty<Document>(
    d => d.Embedding,
    dimensions: 1536,
    metric: VectorDistance.Cosine,
    indexKind: VectorIndexKind.Hnsw));

// 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 matrix

ProviderStorageIndex kindsFilter strategy
PostgreSQLpgvector sidecar tableHNSW, IVF, NonePre-filter via JOIN
SQL Server 2025Native VECTOR(n) sidecarDiskANN, NonePre-filter via JOIN
Oracle 23aiNative VECTOR(n, FLOAT32) sidecarHNSW, IVF, NonePre-filter via JOIN
CosmosDBEmbedded in document JSONDiskANN, QuantizedFlat, FlatWHERE + ORDER BY VectorDistance(...)
MongoDB (Atlas)$vectorSearch aggregationHNSW (Atlas-managed)Filter clause inside $vectorSearch
DuckDBvss sidecar tableHNSW, NonePre-filter via JOIN
SQLitesqlite-vec virtual tableNone (flat scan)Post-filter join back
MySQL / LiteDB / IndexedDBThrows 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 — set vector_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).

Score semantics

MetricSurfaced asDirection
CosineDistance in [0, 2]Lower = closer
Euclidean (L2)DistanceLower = closer
DotProductRaw inner productHigher = closer (negated internally where needed so ORDER BY score ASC works)
HammingBit countLower = closer (PostgreSQL only)

Auto-embed on insert

Wire a Microsoft.Extensions.AI.IEmbeddingGenerator so the vector is populated automatically when the source text is set:

using Shiny.DocumentDb.Extensions.AI;

opts.MapVectorProperty<Document>(d => d.Embedding, dimensions: 1536)
    .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" });

Tuning knobs

VectorIndexOptions exposes the common ANN parameters and a ProviderHints dictionary for the long tail:

opts.MapVectorProperty<Document>(
    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.

Index Management

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.

Create an index on a property

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';

Nested properties

await store.CreateIndexAsync<Order>(o => o.ShippingAddress.City, ctx.Order);

Drop a specific index

await store.DropIndexAsync<User>(u => u.Name, ctx.User);

Drop all JSON indexes for a type

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.

Supported Expression Reference

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.

ExpressionSQL Output (SQLite)
u.Name == "Alice"json_extract(Data, '$.name') = @p0
u.Age > 25json_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() > 1json_array_length(Data, '$.lines') > 1
o.Lines.Count(l => l.Qty > 2)(SELECT COUNT(*) FROM json_each(...) WHERE ...) > 2
e.StartDate > cutoffjson_extract(Data, '$.startDate') > @p0 (ISO 8601 formatted)
e.CreatedAt >= startjson_extract(Data, '$.createdAt') > @p0 (DateTimeOffset supported)
Captured variablesExtracted from closure at translate time

Projection expressions

ExpressionSQL 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