Temporal
TypeGraph tracks temporal validity for all nodes and edges. Use temporal queries to view the graph at a point in time, audit changes, or access historical data.
Temporal Modes
Section titled “Temporal Modes”The temporal() method controls which versions of data are returned:
| Mode | Description |
|---|---|
"current" |
Only currently valid data (default behavior) |
"asOf" |
Data as it existed at a specific timestamp |
"includeEnded" |
All versions, including historical |
"includeTombstones" |
All versions, including soft-deleted |
Current State (Default)
Section titled “Current State (Default)”By default, queries return only currently valid, non-deleted data:
// Returns only current, non-deleted nodesconst currentPeople = await store .query() .from("Person", "p") .select((ctx) => ctx.p) .execute();This is equivalent to:
.temporal("current")Point-in-Time Queries (asOf)
Section titled “Point-in-Time Queries (asOf)”Query the graph as it existed at a specific moment:
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const pastState = await store .query() .from("Article", "a") .temporal("asOf", yesterday) .whereNode("a", (a) => a.id.eq(articleId)) .select((ctx) => ctx.a) .execute();This returns nodes and edges that were valid at the specified timestamp, even if they’ve since been updated or deleted.
Use Cases for asOf
Section titled “Use Cases for asOf”- Auditing: See what data looked like at a specific time
- Debugging: Reproduce issues by querying historical state
- Compliance: Generate point-in-time reports
- Recovery: Find old values before an erroneous update
// What did the user's profile look like last week?const lastWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const historicalProfile = await store .query() .from("User", "u") .temporal("asOf", lastWeek) .whereNode("u", (u) => u.id.eq(userId)) .select((ctx) => ctx.u) .first();Shared-Coordinate Views (store.asOf)
Section titled “Shared-Coordinate Views (store.asOf)”.temporal("asOf", T) pins a single query. When several reads should share one
temporal coordinate, pin it once with store.asOf(T) and reuse the returned
read-only view — TypeGraph’s as-of database value, in the style of Datomic
(d/as-of db t) and SQL:2011 FOR SYSTEM_TIME AS OF.
const past = store.asOf("2024-01-01T00:00:00.000Z");
// Every read on `past` observes the graph as it was valid at that instant.const alice = await past.nodes.Person.getById(aliceId);const jobs = await past.edges.worksAt.findFrom(alice);const peers = await past.reachable(aliceId, { edges: ["knows"] });const team = await past.subgraph(aliceId, { edges: ["reportsTo"] });
const names = await past .query() .from("Person", "p") .whereNode("p", (p) => p.department.eq("Engineering")) .select((ctx) => ctx.p.name) .execute();The view pins the nodes / edges collections (getById, getByIds, find,
count, findFrom, findTo), query(), subgraph(), and the graph
algorithms (reachable, canReach, shortestPath, neighbors, degree).
For the other modes, use store.view({ mode, asOf }):
// A view over every version, including soft-deleted ones.const audit = store.view({ mode: "includeTombstones" });const everyVersion = await audit.nodes.Document.find();A view is read-only: writes stay on the live store, and a view collection
rejects create / update / delete with a ConfigurationError. search is
refused on a non-"current" view (the fulltext / vector index reflects current
state only). asOf must be a canonical UTC ISO-8601 timestamp
(YYYY-MM-DDTHH:mm:ss.sssZ).
See the store.asOf / store.view
reference for the full
surface.
Recorded Time (Bitemporal)
Section titled “Recorded Time (Bitemporal)”The modes above query valid time — when a fact was true in the world
(validFrom / validTo). Recorded time (also called system time) is the
second axis — when a fact was recorded by TypeGraph. With the built-in
captured relation, TypeGraph can run bitemporal graph reads for
TypeGraph-managed writes: you can ask “what did TypeGraph reconstruct as true,
as of a captured commit instant?” — including seeing values that were later
corrected.
Recorded-time capture is opt-in per store, because it writes a history row for every committed TypeGraph collection change:
const store = createStore(graph, backend, { history: true });With history: true, every committed TypeGraph node/edge write is captured into
recorded-time relations (typegraph_recorded_nodes /
typegraph_recorded_edges) stamped with a per-graph monotonic commit instant.
Enable it on a fresh graph: there is no backfill, so an entity that already
exists is first recorded the next time it is written through TypeGraph. Capture
requires a transactional backend with statement execution (the built-in SQLite /
PostgreSQL backends).
Advanced hosts can bind an already-populated recorded relation for reads without using TypeGraph’s writer wrapper:
import { createSqlSchema, recordedRelation } from "@nicia-ai/typegraph";
const recordedRead = recordedRelation({ schema: createSqlSchema({ recordedNodes: "audit_nodes", recordedEdges: "audit_edges", }),});
const store = createStore(graph, backend, { recordedRead });That option only supplies the read source for asOfRecorded(T) reconstruction.
It does not capture writes, advance TypeGraph’s recorded clock, or make
store.recordedNow() available. If TypeGraph should own capture, use
history: true. recordedRead must be created by recordedRelation({ schema })
with a createSqlSchema(...) schema; the store validates those factory
descriptors at runtime and rejects combining them with history: true.
Reading at a recorded instant
Section titled “Reading at a recorded instant”store.asOfRecorded(T) reconstructs the graph as TypeGraph recorded it at
instant T. T is a RecordedInstant: a branded, versioned string containing
both a per-graph logical revision and a physical wall-time high-water mark. It
originates from store.recordedNow() (below), or from
asRecordedInstant(...) when an anchor previously returned by TypeGraph has
round-tripped through untyped storage:
import { asRecordedInstant, recordedInstantWallTime,} from "@nicia-ai/typegraph";
const recorded = store.asOfRecorded( asRecordedInstant("r1:0000000000000042:2024-06-01T12:00:00.000Z"),);
const doc = await recorded.nodes.Document.getById(docId);const cited = await recorded.edges.cites.getByIds(citationIds);const reachable = await recorded.reachable(docId, { edges: ["cites"] });Recorded collections also expose scan() for complete snapshot reconstruction.
Each call returns at most 1,000 entities in canonical id order; use the opaque
nextCursor to continue without retaining a separate identity inventory:
const first = await recorded.nodes.Document.scan({ limit: 500 });const second = first.nextCursor === undefined ? undefined : await recorded.nodes.Document.scan({ limit: 500, after: first.nextCursor, });
const citations = await recorded.edges.cites.scan({ limit: 500 });Scan cursors are forward-only and bound to the graph, entity kind, and both
temporal coordinates. Passing a cursor to another collection or recorded-time
view throws a ValidationError instead of silently skipping data. Iterate each
declared node and edge kind to reconstruct a complete historical graph snapshot.
A raw wall-clock string — store.asOfRecorded(new Date().toISOString()) — does
not type-check, by design. Wall time does not identify which commit to read
when several commits share a millisecond. The anchor’s logical revision provides
that order; its ISO component records a non-decreasing physical wall-time
high-water mark. To pin “as things stand right now” deterministically, use
store.recordedNow() (the recorded high-water mark), then guard the undefined
case before passing it to store.asOfRecorded().
await store.nodes.Document.update(docId, { title: "Revised" });const checkpoint = await store.recordedNow(); // a stable anchor for this stateif (checkpoint === undefined) throw new Error("expected a recorded checkpoint");console.log(recordedInstantWallTime(checkpoint)); // canonical UTC wall time// ...later, however much the graph has changed:const asOfCheckpoint = store.asOfRecorded(checkpoint);recordedNow() is graph-global, not scoped to any one caller or write. It is
the single high-water mark for the whole graph, advanced by every committed
capture from any writer. So a change in recordedNow() across two reads means
“something committed to this graph in between” — not “the write I just made
landed.” Do not use a recordedNow() advance as a per-writer “did my write
succeed?” signal: under any concurrent writer to the same graph it both misses
dropped writes (another writer moved the clock) and misfires on no-op writes. To
confirm a specific write committed, observe the write itself (e.g. its return
value, or run it inside store.transaction(...) and act on success), not the
global clock.
Logical revision and physical time
Section titled “Logical revision and physical time”The canonical encoding is
r1:<16-digit revision>:<canonical UTC timestamp>. Revisions are strict and
monotonic within one graph. The physical component is sampled from the
application clock and clamped to the previous anchor only when that clock moves
backward. It may repeat, but never decreases. TypeGraph does not add one
millisecond per commit, so throughput cannot push recorded wall time beyond the
greatest wall time the graph has actually observed. After a backward clock
correction, the component remains at its prior high-water mark until wall time
catches up.
This non-decreasing physical component preserves cumulative diagonal replay for
default validity timestamps: a later recorded anchor cannot pin valid time
before an earlier commit’s default valid_from.
The fixed-width revision prefix makes anchors lexicographically sortable within
a graph and gives each captured transaction a distinct addressable state. Use
compareRecordedInstants(a, b) rather than manually comparing strings, and
only compare anchors from the same graph. Recorded relations store the revision
as an integer, so their open interval ceiling is independent of the r1 API
encoding and PostgreSQL range scans do not depend on text collation. Recorded
clocks remain per graph, and TypeGraph does not provide one cross-graph recorded
anchor.
Batch related writes in store.transaction(...): one transaction allocates one
recorded instant. For event logs, align transactions with durable replay or
checkpoint boundaries, and cap transaction size separately so an initial sync
does not hold a write lock or capture buffer without bound.
Direct store.asOfRecorded(T) is diagonal bitemporal sugar: it uses the
anchor’s logical revision for the recorded-time axis and its physical wall-time
component for the valid-time axis. To pin the two axes independently — what was
valid at one instant, as TypeGraph captured it at another — chain from a
valid-time view:
// The state valid on Jan 1, as TypeGraph recorded it on Jun 1// (e.g. after a correction was entered later).const corrected = store .asOf("2024-01-01T00:00:00.000Z") .asOfRecorded( asRecordedInstant("r1:0000000000000042:2024-06-01T12:00:00.000Z"), );
const asKnownThen = await corrected.nodes.Invoice.getById(invoiceId);Use recordedInstantRevision(T) for diagnostics and
recordedInstantWallTime(T) for display or logging. Do not split the versioned
anchor string manually.
store.view({ mode }).asOfRecorded(T) composes recorded time with any
valid-time mode — e.g. includeTombstones to reconstruct soft-deleted rows at a
recorded instant.
The recorded view surface
Section titled “The recorded view surface”A RecordedStoreView is a narrow, reconstructing read lens. It exposes only
reads that can be faithfully rebuilt from the recorded relations:
- Point reads —
nodes.<Kind>.getById/getByIds, and the edge equivalents query()— a sealed query builder over the recorded relationssubgraph()and the graph algorithms —reachable,canReach,shortestPath,degree
Broad collection reads (find / count / findFrom / …), search, and
fulltext / vector predicates are refused with a ConfigurationError /
UnsupportedPredicateError: the fulltext and vector indexes reflect current
state only, so they cannot answer a recorded-time question. T must use the
canonical versioned RecordedInstant encoding; a plain ISO timestamp is rejected.
Writing with history enabled
Section titled “Writing with history enabled”Capture flushes at transaction commit, so writes must go through the store’s
typed collections — use store.transaction(...) as usual:
await store.transaction(async (tx) => { await tx.nodes.Document.create({ title: "Draft" });});Raw SQL under history capture
Section titled “Raw SQL under history capture”The portable HistoryStore exposes neither raw SQL nor caller-owned
transaction adoption. If the store was deliberately created through
createAdapterStore(..., { history: true }), raw tx.sql is still disabled
(it would bypass capture), and store.withTransaction(externalTx) is replaced
by the callback form
store.withRecordedTransaction(externalTx, async (tx) => { ... }), which gives
capture a flush point before your transaction commits. Out-of-band database
writes and row-returning raw SQL paths are not audited by the built-in capture
wrapper; use TypeGraph collection writes when the recorded relation is the
source of truth.
The adapter history store’s .backend is a runtime and type-level
HistoryStoreBackend projection. Capture-wrapped graph reads and writes remain
available. executeRaw, executeStatement, executeDdl, trustedImport,
clearGraph, and nested transaction are absent because each can mutate live
rows without a corresponding capture flush. The full guarded backend remains
internal to TypeGraph’s query and transaction implementation.
store.withTransaction on a history-enabled store is a compile error (the
externalTx argument is rejected with a message naming
withRecordedTransaction); the runtime guard still throws ConfigurationError
if suppressed. Inside an AdapterHistoryStore.transaction(...), the typed
context omits tx.sql, and tx.sqlAvailability reports "history" (or
"revisionTracking") so portable code can branch without touching the runtime
guard. Suppressed JavaScript or TypeScript access still throws — see the
tx.sqlAvailability guidance in
Cross-Store Transactions.
Both guards carry a branchable details.code; see
Recorded-capture guard codes.
To write your own relational tables atomically with graph writes on a history
store, pass your transaction handle to withRecordedTransaction and write your
tables through that handle (not tx.sql):
await db.transaction(async (pgTx) => { const { receipt } = await store.withRecordedTransaction(pgTx, async (tx) => { await tx.nodes.Document.update(documentId, props); // graph write }); await pgTx.insert(streamCursors).values(cursorRow); // your own table}); // one COMMIT / ROLLBACK across both layerswithRecordedTransaction returns a
TransactionOutcome<T>: destructure
{ result, receipt }. receipt.writes counts the graph writes (drop
detection) and receipt.recorded is this transaction’s recorded commit instant
— the per-transaction replay anchor. When the callback runs user code that also
bookkeeps, scope a sub-receipt with tx.measure((scoped) => ...): writes through
the scoped context are attributed to the sub-receipt, while the surrounding
bookkeeping written through tx is not.
This is separate from recordedRead: a store created with a recordedRead
binding can reconstruct from a relation populated by another system, but
TypeGraph is not responsible for making that relation complete or atomic with
live writes.
Write cost: batch under history: true
Section titled “Write cost: batch under history: true”Each un-batched write under history: true becomes its own transaction —
it allocates a recorded commit instant under a per-graph clock lock and flushes
one history row at commit. So a tight loop of single create/update/delete
calls pays that fixed cost once per call. Wrapping the same writes in one
store.transaction(...) allocates one recorded instant for the whole batch
and amortizes the overhead to roughly nothing.
Measured per-op latency, identical workload with capture off vs on (history
off → on; N = 400; reproduce with
pnpm --filter @nicia-ai/typegraph-benchmarks bench:recorded-write):
| Workload | SQLite | PostgreSQL |
|---|---|---|
| create — un-batched (per op) | ~2.5× | ~5.5× |
| create — batched in one txn | ~1.5× | ~1.0× |
| update — un-batched (per op) | ~2.8× | ~6× |
| soft delete — un-batched | ~1.7× | ~1.9× |
The takeaway: capture is opt-in and cheap when you batch. Under history: true,
prefer store.transaction(...) for bulk writes; a loop of individual
collection writes is the one pattern that pays the per-write multiple. (Stores
created without history: true are unaffected — graph writes never touch the
capture path.) Batching also reduces recorded-clock consumption: one captured
transaction advances the per-graph clock once, even when it contains many
writes. See Logical revision and physical time
for the anchor format.
Performance. Recorded reads reconstruct from the history relations rather than the live tables, so they are slower than current-state reads — most noticeably for full-graph
subgraph/ algorithm reconstructions on PostgreSQL. Reach forasOfRecordedfor audit and point-in-time reconstruction, not hot-path reads.
Including Historical Data (includeEnded)
Section titled “Including Historical Data (includeEnded)”View all versions, including superseded records:
const history = await store .query() .from("Article", "a") .temporal("includeEnded") .whereNode("a", (a) => a.id.eq(articleId)) .orderBy((ctx) => ctx.a.validFrom, "desc") .select((ctx) => ({ title: ctx.a.title, validFrom: ctx.a.validFrom, validTo: ctx.a.validTo, version: ctx.a.version, })) .execute();
// Result shows all versions:// [// { title: "Final Title", validFrom: "2024-03-01", validTo: undefined, version: 3 },// { title: "Draft v2", validFrom: "2024-02-15", validTo: "2024-03-01", version: 2 },// { title: "Initial Draft", validFrom: "2024-02-01", validTo: "2024-02-15", version: 1 },// ]Audit Trail
Section titled “Audit Trail”Build a complete change history:
async function getAuditTrail(nodeId: string) { return store .query() .from("Document", "d") .temporal("includeEnded") .whereNode("d", (d) => d.id.eq(nodeId)) .select((ctx) => ({ version: ctx.d.version, title: ctx.d.title, status: ctx.d.status, validFrom: ctx.d.validFrom, validTo: ctx.d.validTo, updatedAt: ctx.d.updatedAt, })) .orderBy("d", "version", "asc") .execute();}Including Soft-Deleted Data (includeTombstones)
Section titled “Including Soft-Deleted Data (includeTombstones)”Include records that have been soft-deleted:
const allIncludingDeleted = await store .query() .from("User", "u") .temporal("includeTombstones") .select((ctx) => ({ id: ctx.u.id, name: ctx.u.name, deletedAt: ctx.u.deletedAt, // Will have a value for deleted records })) .execute();Filtering Deleted Records
Section titled “Filtering Deleted Records”// Find only deleted recordsconst deletedUsers = await store .query() .from("User", "u") .temporal("includeTombstones") .whereNode("u", (u) => u.deletedAt.isNotNull()) .select((ctx) => ({ id: ctx.u.id, name: ctx.u.name, deletedAt: ctx.u.deletedAt, })) .execute();Temporal Metadata Fields
Section titled “Temporal Metadata Fields”When querying with temporal context, these fields are available:
| Field | Type | Description |
|---|---|---|
validFrom |
string | undefined |
When this version became valid (undefined on an open-left row — see below) |
validTo |
string | undefined |
When this version was superseded (undefined if current) |
createdAt |
string |
When the node was first created |
updatedAt |
string |
When this version was written |
deletedAt |
string | undefined |
Soft-delete timestamp (undefined if not deleted) |
version |
number |
Optimistic concurrency version number |
Open-left rows (validFrom is undefined)
Section titled “Open-left rows (validFrom is undefined)”A row may have no lower bound at all, which means “valid since forever, as
far as this store knows”. asOf and current treat such a row as valid at
every instant strictly before its validTo, or every instant if it has no end.
These writes produce one:
- a Store create or resurrecting upsert stating
validFrom: null; - an interchange record stating
validFrom: null— a source row confirmed to have no lower bound, round-tripped rather than re-stamped; - a born-already-ended write: one that CREATES a row, or RESETS its window,
while stating a
validToat or before its own instant and novalidFrom. The row’s start is unknown rather than after its end, so no bound is stored and the row reads back at everyasOfbefore that end. AvalidToin the future is unaffected — it still stamps the write instant, so the row stays invisible at instants before it existed. Every node path that resets the window qualifies, and reaches the same stored shape: a create on a fresh id, a create on a tombstoned one, and a resurrectingupsertById/bulkUpsertById. An edge never does: an edge create cannot land on a tombstone (a taken id raisesEdge already exists), and the two paths that resurrect one —bulkUpsertByIdandgetOrCreateByEndpoints— RETAIN the bound the row carries and judge the statedvalidToagainst it.
Rows written by older versions
Section titled “Rows written by older versions”Before that rule existed, a born-already-ended write stored the write instant as
valid_from, leaving a window that runs backwards — a row readable at no
coordinate at all. Upgrading does not rewrite such rows; they keep their window
and stay invisible until an operator repairs them explicitly with
repairInvertedValidityWindows, which normalizes them to the open-left shape
above. Prefer relations: "live-and-recorded": repairing only the live axis
leaves the recorded twin inverted, so asOfRecorded reads keep returning the
invisible shape. See
Repairing inverted validity windows
for the operator checklist — run it with writers stopped, and re-baseline merge
branches afterwards.
.select((ctx) => ({ ...ctx.a, // All node properties validFrom: ctx.a.validFrom, validTo: ctx.a.validTo, createdAt: ctx.a.createdAt, updatedAt: ctx.a.updatedAt, deletedAt: ctx.a.deletedAt, version: ctx.a.version,}))Temporal Traversals
Section titled “Temporal Traversals”Temporal modes apply to traversals as well:
// See who worked at a company last yearconst lastYear = new Date("2023-01-01").toISOString();
const pastEmployees = await store .query() .from("Company", "c") .temporal("asOf", lastYear) .whereNode("c", (c) => c.name.eq("Acme Corp")) .traverse("worksAt", "e", { direction: "in" }) .to("Person", "p") .select((ctx) => ({ name: ctx.p.name, role: ctx.e.role, })) .execute();store.subgraph() and store.algorithms.* accept the same temporalMode
and asOf options, defaulting to graph.defaults.temporalMode. See
Temporal Behavior for the algorithm
surface and store.subgraph() options
for subgraph.
Real-World Examples
Section titled “Real-World Examples”Version Comparison
Section titled “Version Comparison”Compare two versions of a document:
async function compareVersions(docId: string, v1: number, v2: number) { const versions = await store .query() .from("Document", "d") .temporal("includeEnded") .whereNode("d", (d) => d.id.eq(docId)) .select((ctx) => ctx.d) .execute();
const version1 = versions.find((v) => v.version === v1); const version2 = versions.find((v) => v.version === v2);
return { version1, version2 };}Compliance Reporting
Section titled “Compliance Reporting”Generate a report as of a specific date:
async function generateQuarterlyReport(quarterEnd: string) { const activeContracts = await store .query() .from("Contract", "c") .temporal("asOf", quarterEnd) .whereNode("c", (c) => c.status.eq("active")) .traverse("belongsTo", "e") .to("Customer", "cust") .select((ctx) => ({ contractId: ctx.c.id, value: ctx.c.value, customer: ctx.cust.name, })) .execute();
return { asOf: quarterEnd, totalContracts: activeContracts.length, totalValue: activeContracts.reduce((sum, c) => sum + c.value, 0), contracts: activeContracts, };}Undo/Recovery
Section titled “Undo/Recovery”Find the previous value before an update:
async function getPreviousVersion(nodeId: string) { const versions = await store .query() .from("Document", "d") .temporal("includeEnded") .whereNode("d", (d) => d.id.eq(nodeId)) .select((ctx) => ctx.d) .orderBy("d", "version", "desc") .limit(2) .execute();
return { current: versions[0], previous: versions[1], };}Next Steps
Section titled “Next Steps”- Filter - Filtering with predicates
- Traverse - Graph traversals
- Execute - Running queries
- Bitemporal Time Travel - Valid time plus recorded time in one runnable example
- Agent Decision Replay - Reconstruct the exact graph an agent saw
- Breach Forensics - Traverse a reconstructed access graph at the breach instant