Skip to content

Historical Queries

Quine Enterprise records every change to every node. Instead of overwriting a node's properties and edges in place, each change is appended to that node's own journal along with the moment it happened, a technique known as event sourcing. Replaying that journal up to a chosen moment rebuilds the node exactly as it stood then.

A historical query is a read answered from the graph as it was at a moment you choose. The history comes free with ordinary ingest: there is nothing to configure when the data is written, and nothing to declare in advance about which moments you might want back.

Two questions come up, and Quine Enterprise answers them with different tools:

Most investigations use both: find the moment that matters with the second, then read the graph at it with the first. Since the moment is an ordinary Cypher argument, both halves can happen in a single query.

What history requires

Historical queries read the journal, so keeping the journal is what keeps them available.

Setting Default Effect on historical queries
quine.persistence.journal-enabled true Keep this enabled. The journal is the per-change record a historical query replays; with journal-enabled = false, a historical query can report only what a snapshot happened to capture. The history.* procedures require it outright and say so when it is off.
quine.persistence.snapshot-schedule on-node-sleep Snapshots make replay faster. A node is rebuilt from its most recent snapshot plus the journal since, rather than from the beginning of its history.
quine.persistence.snapshot-singleton false Keep the default. Snapshots are keyed by the time they were taken, so a historical read replays from the newest snapshot at or before the moment asked for. With true, a historical read returns present-day state instead; see the warning below.

snapshot-singleton = true returns present-day data for a past moment

With snapshot-singleton = true, a node keeps one snapshot row holding only its latest state, and a historical read starts from that row. It reports the node's present-day state for whatever past moment you asked about: the query succeeds, the results look ordinary, and nothing reports an error.

A node writes this row when it sleeps. Before its first sleep there is no such row, so a historical read replays the journal and reports the state at the moment you asked for. Present-day answers start after that first sleep.

This affects every read that rebuilds a node at a moment: the atTime parameter, history.nodeAt, and history.queryAt. The history.*Changes procedures read the journal rather than a snapshot, so they report the same changes and timestamps whatever this setting is.

See Persistors for the full persistence configuration.

Two more things to know before you build on historical results:

  • A historical moment is read-only. A query pinned to the past that tries to write returns an error, so a write can never land silently against a moment that has already passed.
  • purgeNode removes a node's history. Once purged, moments that node participated in can no longer be reconstructed. Avoid purgeNode in a deployment that relies on historical queries.

  • Historical results rely on synchronized clocks. A moment is a wall-clock timestamp, and each cluster member stamps its own writes. Run NTP on every member so that a historical query assembles its answer from one consistent moment. See Common Pitfalls.

snapshot-after-events

The three settings in the table above decide whether history is available. This one decides what it costs: with all three left at their defaults, quine.persistence.snapshot-after-events sets how many events a node must journal before a sleep writes a snapshot. Raising it means fewer snapshots and more journal replayed to reach a past moment, so answers are unchanged but take longer to produce. It is refused in any other combination, so it is only ever available when the settings above are already right.

Querying at a past moment

Every graph-scoped read endpoint that can be pinned to a moment accepts an atTime query parameter, an RFC 3339 timestamp such as 2026-04-27T15:30:00Z. It must not be in the future.

curl -X "POST" "http://127.0.0.1:8080/api/v2/graph/quine/cypher:query?atTime=2026-04-27T15:30:00Z" \
     -H 'Content-Type: text/plain' \
     -d "MATCH (n:Account {id: 'A1'}) RETURN n.balance AS balance"

The query runs exactly as it would against the present graph. Every node it touches is rebuilt at that moment first, so MATCH traverses the edges that existed then and returns the property values that were set then.

These endpoints accept atTime:

Endpoint What it returns at that moment
POST /api/v2/graph/{graphName}/cypher:query Arbitrary Cypher results
POST /api/v2/graph/{graphName}/cypher:queryNodes Nodes only
POST /api/v2/graph/{graphName}/cypher:queryEdges Edges only
GET /api/v2/graph/{graphName}/hashCode A hash of the graph's state, useful for confirming two moments agree
POST /api/v2/graph/{graphName}/algorithms/randomWalk/nodes/{nodeId}:generateRandomWalk A random walk over the graph as it was
POST /api/v2/graph/{graphName}/algorithms/randomWalk:saveWalks A saved set of walks over the graph as it was

Moving from API v1

API v1 spelled this parameter at-time and took epoch milliseconds. API v2 spells it atTime and takes an RFC 3339 timestamp. See Migrating from API v1.

From the Exploration UI

The Exploration UI exposes the same capability through its clock button, tooltipped Querying for time: choose a moment, and every query issued from then on runs against the graph as it stood at that moment. Choosing a moment starts a new exploration session and clears the canvas, because the results already rendered describe a different moment.

Choosing a moment

A historical query answers about one instant, so picking the right instant is the work. Two approaches help:

  • Reify time into the graph. The reify.time procedure builds nodes representing periods — years, months, days, minutes — and attaches your data to them. You can then find the period you care about by querying the graph normally and read its timestamp off the time node.
  • Record the moment on the data itself. If your ingest writes a timestamp property, an ordinary query finds the events of interest and hands you the moments to query at.

Both give you a moment to pass as atTime. From inside Cypher, history.queryAt takes the moment as an argument, so a single query can find a moment and read the graph at it.

Reading a node's history from Cypher

The atTime parameter pins a whole request to one moment, which is what you want when you already know the moment. When the question is when did this change, Quine Enterprise Enterprise provides six Cypher procedures that read a node's journal directly.

They fall into two groups:

Procedure Arguments Yields
history.propertyChanges node, propertyKey?, options? key, value, previousValue, changeTime
history.edgeChanges node, other?, edgeType?, direction?, options? action, edgeType, direction, other, changeTime
history.edgeChangesBetween node, other, edgeType?, direction?, options? action, edgeType, direction, other, changeTime
history.nodeChanges node, options? kind, detail, changeTime
history.nodeAt node, atTime node, edges
history.queryAt query, atTime, parameters? value

The *Changes procedures report what changed and when. The *At procedures report what the graph was at a moment. Used together, one query can find a moment and read the graph at it.

Available in Quine Enterprise

These procedures are registered in Quine Enterprise only. In Quine OSS, the atTime request parameter is the way to run a historical query.

Conventions shared by all six

  • Every node argument accepts a Cypher node, a node ID as a string, or raw ID bytes.
  • Arguments are positional. Pass null for any argument you are skipping — history.edgeChanges(n, null, "KNOWS") filters by edge type without naming the far node.
  • All timestamps are milliseconds since the Unix epoch, in arguments and in the changeTime column alike. Convert for display with datetime({epochMillis: changeTime}).
  • All six require the journal. Each names the setting in an error when journal-enabled is false, so an answer that would have been incomplete is never mistaken for a node that never changed. history.nodeAt is included: rebuilding a node at a past moment replays its journal from the most recent snapshot.
  • A moment that has not arrived is used as given. Only what has already happened can be reported, so a timestamp slightly ahead of the present reads the same as the present.
  • A procedure never reports past the query containing it. When one of these runs inside a request already pinned by atTime, the earlier of the two moments applies, so a query asking about last Tuesday stays a view of last Tuesday.

The options map

The four *Changes procedures take a final options map. Unrecognized keys are rejected, so a mistyped option cannot silently widen the window you are auditing.

Key Type Meaning
since integer Earliest millisecond to report, inclusive. Applied in the data store query, so a narrow window reads less.
through integer Latest millisecond to report, inclusive. Spelled through because until is a reserved word in Cypher.
limit integer Greatest number of rows to return. Cancellation reaches back into the journal read, so a small limit does a small amount of reading.
unreadableAsNull boolean Report a value that cannot be deserialized as null instead of returning an error. Accepted by history.propertyChanges and history.nodeChanges, the two procedures that report values.

A since later than its through is rejected.

Half edges and whole edges

Quine Enterprise stores an edge as two half edges, one held by each endpoint. A traversal follows an edge only when both nodes hold their half: MATCH asks the far node to confirm before continuing, and skips the edge when it cannot.

The edge procedures give you both views:

  • history.edgeChanges reads one node's journal and reports what that node recorded. It is the cheaper of the two, it is the one that shows a half edge whose counterpart was never written, and an edge it lists is not necessarily one MATCH will walk.
  • history.edgeChangesBetween reads both journals and applies the traversal rule over time: an edge is reported as added once both nodes hold their half, and as removed as soon as either drops it. The periods it reports are the periods a MATCH would have walked the edge.

The two take their arguments in the same order, so a row from the first feeds straight into the second. history.nodeAt lists half edges as well, for the same one-journal economy, so a MATCH at that moment may have traversed fewer edges than it lists.

history.propertyChanges

Every recorded change to a node's properties, in the order they happened.

MATCH (n:Account) WHERE id(n) = idFrom("account", "A123")
CALL history.propertyChanges(n, "balance")
YIELD value, previousValue, changeTime
RETURN datetime({epochMillis: changeTime}) AS at, previousValue, value
ORDER BY changeTime

Every change is reported as a setting, matching how it is written in Cypher, with a removal reported as a setting to null. previousValue is what the property held immediately before, as far back as the reported window shows, and is null for the first reported setting of a key.

Omit propertyKey, or pass null, to report every property:

MATCH (n:Person {name: "Alice"})
CALL history.propertyChanges(n)
YIELD key, value, changeTime
RETURN key, count(*) AS changes, max(changeTime) AS lastChanged
ORDER BY changes DESC

A value that cannot be deserialized returns an error naming the option that relaxes it, which keeps it distinct from a removal, since a removal is itself reported as a setting to null.

history.edgeChanges

Every recorded change to the half edges one node holds, in the order they happened, reported as "added" or "removed" with the edge type, the node at the far end, and the direction this node holds it in.

MATCH (alice:Person {name: "Alice"}), (bob:Person {name: "Bob"})
CALL history.edgeChanges(alice, bob, "KNOWS")
YIELD action, edgeType, changeTime
RETURN action, edgeType, datetime({epochMillis: changeTime}) AS at
ORDER BY changeTime

edgeType also accepts a list, reporting an edge matching any type named. This is the value-level counterpart of -[:KNOWS|WORKS_WITH]->, which cannot be written as a procedure argument:

CALL history.edgeChanges(alice, bob, ["KNOWS", "WORKS_WITH"])

An empty list matches no type, the way x IN [] does, so a computed list that comes back empty returns no rows.

direction accepts every spelling the REST API accepts (Outgoing/outgoing/out, and so on) and always reports the canonical capitalized form. Omit it, or pass null, for every direction.

history.edgeChangesBetween

Every recorded change to whole edges between two nodes, the traversable view described above. Both nodes are required, so this reads exactly two journals.

MATCH (employee:Employee {id: "EMP123"}), (company:Company {name: "TechCorp"})
CALL history.edgeChangesBetween(employee, company, "WORKS_FOR")
YIELD action, changeTime
RETURN
  datetime({epochMillis: changeTime}) AS at,
  CASE action WHEN "added" THEN "hired" WHEN "removed" THEN "left" END AS event
ORDER BY changeTime

Because an edge exists only while both halves do, the timestamps bound the period conservatively: an addition takes the later of the two half-edge additions, when both halves are first in place, and a removal takes the earlier of the two half-edge removals, when the edge first becomes incomplete. The lifetimes reported are the periods the whole edge was traversable.

Two half edges belong to the same edge only when they agree on the edge type and their directions are the reverse of each other. Where more than one edge connects the same pair of nodes — source -> target and target -> source of the same type, or a directed and an undirected edge of the same type — each is a distinct edge with its own lifecycle.

A row from history.edgeChanges feeds straight in, which is how you ask when a half edge you found became traversable:

MATCH (n:Account) WHERE id(n) = idFrom("account", "A123")
CALL history.edgeChanges(n) YIELD edgeType, direction, other
CALL history.edgeChangesBetween(n, other, edgeType, direction)
YIELD action, changeTime
RETURN edgeType, other, action, changeTime

How since narrows this procedure

Whether an edge is whole depends on two half edges held on two different nodes, so a since bound, which starts both journal reads partway through, changes what a single half-edge event inside the window can be read to mean:

  • An edge is reported as added once both of its halves are added inside the window. An edge whose two halves straddle the bound is reported as added in neither window.
  • A removal is reported whenever a half edge is dropped inside the window and its counterpart is not known to be already gone, since dropping either half ends the edge. Clearing up a half edge whose counterpart was never written is therefore reported as a removal.

Omit since for exact answers: an unbounded call reads each node's history from the beginning, and both the additions and the removals it reports are complete. through and atTime narrow the end of the history rather than its beginning, so they carry no such caveat, and neither do the other three *Changes procedures, whose rows each describe one node's own record. If a windowed result looks incomplete, widen the window or drop since.

Self loops

A node at both ends of an edge holds both halves itself, so this procedure reads one journal and tells the halves apart by their direction.

  • A directed loop has two halves, Outgoing and Incoming, both on that node, and is whole once both exist. It is reported once, as Outgoing.
  • An undirected loop has both halves spelled identically, so one half edge is recorded and that half edge is the whole edge. This is the one case in Quine Enterprise where a single half edge makes a whole one.

history.nodeChanges

Everything a node recorded about itself, property changes and edge changes together, in the order it wrote them, from a single reading of its journal. Asking the property and edge procedures separately reads the journal twice.

Each change comes back as a map in detail, with kind saying how to read it:

kind Shape of detail
"property" {key, value, previousValue}, with a removal reported as a setting to null
"edge" {action, edgeType, other, direction}, where action is "added" or "removed"
MATCH (n:Account) WHERE id(n) = idFrom("account", "A123")
CALL history.nodeChanges(n)
YIELD kind, detail, changeTime
RETURN datetime({epochMillis: changeTime}) AS at, kind, detail

Because detail is a map, its fields can be reached directly, so one call answers questions that would otherwise take two procedures:

MATCH (n:Account) WHERE id(n) = idFrom("account", "A123")
CALL history.nodeChanges(n, {since: 1693526400000})
YIELD kind, detail, changeTime
WHERE kind = "edge" AND detail.action = "removed"
RETURN detail.edgeType, detail.other, changeTime

This procedure takes no edge type or direction filter of its own, as the edge procedures do. It reports everything the node recorded, and WHERE on detail narrows it.

Reading the graph at a moment

history.nodeAt

The whole node as it stood at a moment: the properties and labels it had, and the half edges it held, each as {edgeType, other, direction}.

MATCH (n:Account) WHERE id(n) = idFrom("account", "A1")
CALL history.nodeAt(n, 1693526400000) YIELD node AS past, edges AS edgesThen
RETURN past.balance AS balanceThen, n.balance AS balanceNow, size(edgesThen) AS edgeCountThen

The edges are an ordinary list of maps:

MATCH (n:Account) WHERE id(n) = idFrom("account", "A1")
CALL history.nodeAt(n, 1693526400000) YIELD edges
UNWIND edges AS edge
WITH edge WHERE edge.direction = "Outgoing"
RETURN edge.edgeType, edge.other

Because the moment is an argument, it can come from the graph, which lets one query compare a node against itself at many moments:

MATCH (n:Account {id: "A1"}), (audit:AuditPoint)
CALL history.nodeAt(n, audit.timestamp) YIELD node AS past
RETURN audit.timestamp, past.balance
ORDER BY audit.timestamp

history.queryAt

Runs a read-only Cypher query as of a historical moment, yielding one map per row keyed by the query's return columns. This is the atTime request parameter moved inside the query.

CALL history.queryAt("MATCH (n:Account {id: 'A1'}) RETURN n.balance AS balance", 1693526400000)
YIELD value
RETURN value.balance

Results come back as a map per row, since the query's columns are not known until it is compiled.

Reach for this over the request parameter when one query needs to read several different moments, or to compute each moment from data:

MATCH (audit:AuditPoint)
CALL history.queryAt(
  "MATCH (n:Account {id: $id}) RETURN n.balance AS balance",
  audit.timestamp,
  {id: audit.accountId}
)
YIELD value
RETURN audit.timestamp, value.balance
ORDER BY audit.timestamp

Parameters are referenced as $name inside the query string, as in any other Cypher query.

The query must be read-only. One that writes returns an error naming the query at fault.

Next steps