Skip to main content

Temporal Graphs: Modeling State and Event Time Together

Most business questions have two modes:

  • Current state: what is a customer's plan right now?
  • History: what changed, when, and what caused it?

A flat record answers the first question. It cannot answer the second — a single PATCH replaces what was there before and leaves no trace.

A temporal graph models both. The entity holds its current state. Events describe each change, when it happened, and what triggered it. Relationships between entities and events let you answer both questions with the same query engine.


The pattern

  • STATE nodes carry the actual field values with a since timestamp
  • EVENT nodes record what happened and when, without storing the full state
  • The SUPERSEDED_BY chain lets you reconstruct history at any point in time

Step 1: Create the entity with initial state

from rushdb import RushDB

db = RushDB("RUSHDB_API_KEY", base_url="https://api.rushdb.com/api/v1")

user = db.records.create("ENTITY", {
"entityId": "user-88",
"type": "customer",
"name": "Priya Kapoor",
"email": "priya@example.com"
})

state1 = db.records.create("STATE", {
"plan": "pro",
"monthlyLimit": 50000,
"since": "2025-01-10T00:00:00Z",
"isCurrent": True
})

db.records.attach(user.id, state1.id, {"type": "HAS_STATE"})

Step 2: Record a state change as an event

Use a transaction to atomically create the new state and the event, then update the old state in the same operation.

tx = db.transactions.begin()

try:
# Mark old state
db.records.update(state1.id, {"isCurrent": False}, tx)

# New state
state2 = db.records.create("STATE", {
"plan": "enterprise",
"monthlyLimit": 500000,
"since": "2025-03-20T14:00:00Z",
"isCurrent": True
}, transaction=tx)

# Event
event1 = db.records.create("EVENT", {
"type": "plan_upgraded",
"occurredAt": "2025-03-20T14:00:00Z",
"previousPlan": "pro",
"newPlan": "enterprise",
"triggeredBy": "billing-agent"
}, transaction=tx)

db.records.attach(state1.id, state2.id, {"type": "SUPERSEDED_BY"}, transaction=tx)
db.records.attach(user.id, state2.id, {"type": "HAS_STATE"}, transaction=tx)
db.records.attach(user.id, event1.id, {"type": "EXPERIENCED"}, transaction=tx)
db.records.attach(event1.id, state2.id, {"type": "RESULTED_IN"}, transaction=tx)

db.transactions.commit(tx)
except Exception:
db.transactions.rollback(tx)
raise

Step 3: Query current state

current_state = db.records.find({
"labels": ["STATE"],
"where": {
"isCurrent": True,
"ENTITY": {
"$relation": {"type": "HAS_STATE", "direction": "in"},
"entityId": "user-88"
}
},
"limit": 1
})

Step 4: Query full event history for an entity

history = db.records.find({
"labels": ["EVENT"],
"where": {
"ENTITY": {
"$relation": {"type": "EXPERIENCED", "direction": "in"},
"entityId": "user-88"
}
},
"orderBy": {"occurredAt": "asc"}
})

Step 5: Reconstruct state at a point in time

Find the state that was current on a given date by checking which state node was created before that date and had not yet been superseded (or has no SUPERSEDED_BY link).

state_on_date = db.records.find({
"labels": ["STATE"],
"where": {
"since": {"$lte": {"$year": 2025, "$month": 2, "$day": 15}},
"ENTITY": {
"$relation": {"type": "HAS_STATE", "direction": "in"},
"entityId": "user-88"
}
},
"orderBy": {"since": "desc"},
"limit": 1
})

Step 6: Compute event metrics by type over a time window

upgrades = db.records.find({
"labels": ["EVENT"],
"where": {
"type": "plan_upgraded",
"occurredAt": {
"$gte": {"$year": 2025, "$month": 1, "$day": 1},
"$lt": {"$year": 2026, "$month": 1, "$day": 1}
}
},
"select": {
"eventCount": {"$count": "*"},
"firstUpgrade": {"$min": "$record.occurredAt"},
"lastUpgrade": {"$max": "$record.occurredAt"}
},
"groupBy": ["eventCount", "firstUpgrade", "lastUpgrade"]
})

When to use this pattern vs. simple field updates

ScenarioApproach
Only current state mattersUpdate the field in-place with PATCH
You need to know what changed and whenTemporal graph with EVENT nodes
You need to reconstruct state at any past dateSTATE chain with since and isCurrent
Compliance requires immutable audit trailAppend-only STATE + EVENT nodes; never delete

Production caveat

State and event chains grow indefinitely. Set a retention horizon: archive or delete STATE nodes where isCurrent = false and since is older than your compliance window. Use db.records.delete with a where filter for bulk archival. Keep at least one historical STATE per entity per quarter if you need YoY comparisons.


Next steps