Skip to content
Shiny.NET

Document Metadata

Every provider already records when a document was first written and when it last changed — the CreatedAt and UpdatedAt the store keeps next to the body (relational columns, or the envelope fields on the document providers) — and, with shared-table multi-tenancy, which tenant owns it. Declare a DocumentMetadata property and those values show up on the document itself, on every read and after every write:

public class Order
{
public string Id { get; set; } = "";
public decimal Total { get; set; }
public DocumentMetadata? Metadata { get; set; }
}
var order = new Order { Id = "o1", Total = 42 };
await store.Insert(order);
order.Metadata!.CreatedAt; // stamped by the write — no re-read needed
var loaded = await store.Get<Order>("o1");
loaded!.Metadata!.UpdatedAt; // stamped from the store on read

There is no mapping call. The store finds the property by its type, so name it whatever you like — Metadata, Audit, Stamps. Queries use that name: with public DocumentMetadata? Audit { get; set; } the string grammar is Where($"Audit.CreatedAt > {t}") (C# or JSON name, case-insensitive).

Member Meaning
CreatedAt When the document was first written.
UpdatedAt When the document was last written.
TenantId The tenant that owns the document when the store uses shared-table multi-tenancy (TenantIdAccessor); null otherwise.
IsPersisted true once the store has stamped this instance (a read or a successful write). false on an instance you constructed yourself, and on temporal snapshots.

The setters are internal: application code reads these values, only the store writes them.

A document the store hands back never has a null Metadata — if the property is null on load, the store creates the object and assigns it. You don’t need = new() and you don’t need null checks on stored documents. The same applies after a write: Insert, Update and Upsert assign and stamp the instance you passed in.

Because the store assigns it, the property must be settable — { get; set; }, { get; init; }, or a non-public setter marked [JsonInclude]. A get-only property is rejected: at startup for a type you configured with ConfigureDocument<T> (or declared on a DocumentContext), otherwise on first use. A type may declare at most one DocumentMetadata property.

Declaring it nullable (DocumentMetadata?) is the honest shape: an object you’ve created but not yet saved has no metadata until the first write stamps it.

The envelope is the only copy. The store strips the property before it writes the JSON body, so the timestamps can’t drift out of step, and raw-JSON reads (ToJsonList, WriteJsonArrayTo, ToJsonCursorPage) return the body as stored — without metadata.

Outside the store the object serializes normally, so an API that returns the document returns its timestamps:

{ "id": "o1", "total": 42, "metadata": { "createdAt": "2026-09-21T14:03:11.2841930+00:00", "updatedAt": "…" } }

tenantId is written only when it’s set. A metadata member sent back by a client is ignored — the store never writes it.

With shared-table multi-tenancy (DocumentStoreOptions.TenantIdAccessor), TenantId reports the tenant the document is stored under — on every read (Get, queries, joins, session reads) and on the instance you just wrote. Every read and write is already scoped to the current tenant, so this is always the tenant the accessor returned for that operation:

var order = new Order { Id = "o1" };
await store.Insert(order);
order.Metadata!.TenantId; // "acme" — the tenant this write was scoped to

TenantId can’t be queried — a Where on it throws, since every query is already filtered to the current tenant. It is null on a store without TenantIdAccessor, including tenant-per-database routing (where the store itself holds no tenant column) and every non-relational provider, which doesn’t support shared-table tenancy.

Where and OrderBy on CreatedAt/UpdatedAt run against the envelope, not the body:

var stale = await store.Query<Order>()
.Where(x => x.Metadata!.UpdatedAt < DateTimeOffset.UtcNow.AddDays(-30))
.OrderBy(x => x.Metadata!.CreatedAt)
.ToList();
// the string grammar resolves the same path
var recent = await store.Query<Order>().Where($"Metadata.CreatedAt > {since}").ToList();
var newest = await store.Query<Order>().OrderBy("Metadata.CreatedAt", "desc").ToList();

Comparisons are by instant: a cutoff with a non-UTC offset matches the same rows as its UTC equivalent. On the relational providers the predicate compares the CreatedAt/UpdatedAt columns directly (they are not indexed by default — add an index if you filter on them heavily). IsPersisted is not stored and can’t be queried; TenantId can’t be queried either (see Multi-tenancy).

Projections work too — Select(x => new { x.Id, x.Metadata!.UpdatedAt }) and Project("Id, Metadata.UpdatedAt"). Because the timestamps aren’t in the body a SQL json_object projection reads, a relational projection that touches metadata filters, orders and pages in SQL and shapes the rows client-side.

Provider Metadata Where / OrderBy
SQLite, SQLCipher, DuckDB, PostgreSQL, CockroachDB, MySQL, MariaDB, SQL Server, Oracle In SQL, on the CreatedAt / UpdatedAt columns
MongoDB, Amazon DocumentDB In the query, on the envelope’s createdAt / updatedAt fields
Cosmos DB In the query, on c.createdAt / c.updatedAt
Firestore Pushed down as a filter on _meta.createdAt / _meta.updatedAt (Firestore’s one-range-field rule still applies)
Azure Table, DynamoDB Pushed into the OData filter / filter expression on the CreatedAt / UpdatedAt attributes
Redis, RavenDB, LiteDB, IndexedDB Evaluated in memory over the candidates, which are stamped before the filter runs

Every provider returns the same results — the difference is only where the work happens.

The value stamped on the instance you wrote is exactly the value a later read returns. Each write takes one timestamp at the precision the backend stores (microseconds on the relational engines, milliseconds on BSON-date stores) and uses it for both.

Write CreatedAt UpdatedAt
Insert, BatchInsert set set
Update, Update(patch: true) kept as your instance had it set
Upsert set when the provider knows the insert branch ran; otherwise kept as your instance had it set

A relational native upsert (ON CONFLICT / MERGE) doesn’t report which branch it took, so a fresh object upserted over an existing row shows the stored CreatedAt only after it’s re-read. A write cancelled by an interceptor stamps nothing.

History rows are snapshots of the body, with no envelope of their own. History, AsOf, AsOfAll, ChangesByActor and ChangesBetween new the property up (so it’s still never null) but leave it unstamped — IsPersisted is false and TenantId is null. Restore keeps the live document’s CreatedAt and stamps UpdatedAt with the restore write.

  • JSON collections (IJsonDocumentCollection) and raw-JSON terminals return the body only — there’s no typed object to stamp.
  • Native change-feed payloads that carry only the body get a new, unstamped instance.
  • A DocumentMetadata on a nested object is plain data: only the root document carries envelope metadata, and a nested one can’t be queried.