Skip to content

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.

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

By default, queries return only currently valid, non-deleted data:

// Returns only current, non-deleted nodes
const currentPeople = await store
.query()
.from("Person", "p")
.select((ctx) => ctx.p)
.execute();

This is equivalent to:

.temporal("current")

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.

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

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

The modes above query valid timewhen 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.

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 state
if (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.

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.

A RecordedStoreView is a narrow, reconstructing read lens. It exposes only reads that can be faithfully rebuilt from the recorded relations:

  • Point readsnodes.<Kind>.getById / getByIds, and the edge equivalents
  • query() — a sealed query builder over the recorded relations
  • subgraph() 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.

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

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 layers

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

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 for asOfRecorded for audit and point-in-time reconstruction, not hot-path reads.

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 },
// ]

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();
// Find only deleted records
const 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();

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

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 validTo at or before its own instant and no validFrom. The row’s start is unknown rather than after its end, so no bound is stored and the row reads back at every asOf before that end. A validTo in 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 resurrecting upsertById / bulkUpsertById. An edge never does: an edge create cannot land on a tombstone (a taken id raises Edge already exists), and the two paths that resurrect one — bulkUpsertById and getOrCreateByEndpoints — RETAIN the bound the row carries and judge the stated validTo against it.

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 modes apply to traversals as well:

// See who worked at a company last year
const 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.

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

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

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