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 readThere 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).
What’s on it
Section titled “What’s on it”| 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.
The store news it up
Section titled “The store news it up”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.
Never stored in the body
Section titled “Never stored in the body”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.
Multi-tenancy
Section titled “Multi-tenancy”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 toTenantId 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.
Querying
Section titled “Querying”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 pathvar 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.
Where the comparison runs
Section titled “Where the comparison runs”| 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.
Writes and precision
Section titled “Writes and precision”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.
Temporal history
Section titled “Temporal history”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.
Where it isn’t populated
Section titled “Where it isn’t populated”- 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
DocumentMetadataon a nested object is plain data: only the root document carries envelope metadata, and a nested one can’t be queried.


