Mental Model for Database Developers
RelataDB is a database — you ingest rows and query them. If you have used any of the systems in the left column of the table below, the right column shows you where each familiar concept lands in Relata. The goal of this page is to give you a working mental model in under ten minutes so the rest of the docs read like an extension of what you already know.
RelataDB has no CREATE TABLE. You declare ObjectType, EventType, and LinkType. Everything else — storage layout, query planner, protocol doors, SDKs — is derived from those declarations. If you remember nothing else, remember the three primitives in the next section.
Mental model at a glance
| You already know | In RelataDB | Notes |
|---|---|---|
| Postgres / MySQL — table | ObjectType (or EventType for time-anchored facts) | One declaration per "kind of thing." Schema-as-code, git-branched. |
| Postgres / MySQL — row | A bi-temporal row | Same shape regardless of primitive; carries four timestamps (see below). |
| Postgres / MySQL — column | Typed property | Scalar, canonical (76 kinds: phone, IBAN, IBAN, …), vector, identity. |
Postgres — JOIN on foreign key | LinkType or Identity-typed property | Identity-typed joins are auto-resolved by the engine — no JOIN needed. |
| Postgres — materialized view | MATERIALIZED VIEW (CREATE MATERIALIZED VIEW … AS …) | Refreshing MVs run on the job engine, never in the query path. |
| Postgres — trigger / CDC | ActionType or typed Job | See Jobs, Workflows & Detection. |
| MongoDB — collection | ObjectType (or EventType) | The collection name is the type; documents are rows. |
MongoDB — _id | ObjectId (byte-borrow of RowId) | Server-minted monotonic counter; not client-supplied on insert. |
| MongoDB — embedded document | Typed property with nested object, or a separate ObjectType + LinkType | Pick LinkType if the embedded thing is queried independently. |
MongoDB — aggregation $lookup | LinkType traversal or RESOLVE_IDENTITY or IDENTITY_CLUSTER | Graph traversal is one MATCH … RETURN away. |
| DynamoDB — table + sort key | ObjectType with cluster_key declared on the primary key property | Range scans on the cluster key are first-class. |
| DynamoDB — GSI / LSI | Secondary indexes declared on the type | Vector, BM25, identity, range — all declared once, used anywhere. |
| Redis — key + value | KvEntry type (built-in) when accessed via the Redis door | SET foo bar writes one KvEntry row; queryable over SQL. |
| Cassandra — table + partition key | ObjectType with cluster_key | Same partitioning model, but bi-temporal + governed by default. |
| Neo4j — node label | ObjectType | Query via Cypher over the Neo4j door. |
| Neo4j — edge type / relationship | LinkType | Directed, typed, bi-temporal like everything else. |
| Snowflake / BigQuery — table + ETL pipeline | ObjectType + a typed Job that maintains it | Relata does identity resolution in the engine, not upstream ETL. |
| Any — audit log table you built yourself | Built-in audit view + PROV-O provenance on every row | Hash-chained, tamper-evident; see Provenance. |
The three primitives — and when to pick which
The hardest part for someone coming from a single-paradigm database is that RelataDB exposes three row-shaped primitives instead of one. Pick by asking what kind of thing is this row?
| If the row is a… | Declare it as | Why |
|---|---|---|
| Thing with stable identity that exists over time (a user, a device, a case, a contract) | ObjectType | Survives property changes via bi-temporal versions; identity is the durable thing. |
| Time-anchored occurrence (a login, a transaction, a message, a sensor reading) | EventType | Bi-temporal by construction; the fact that it happened is the primary content. |
| Directed, typed connection between two instances (Alice OWNS Device-42, Post-7 MENTIONS Topic-12) | LinkType | First-class edges; queryable as a graph without a join. |
ObjectType ────────► ObjectType
│ ▲
│ LinkType │
▼ │
ObjectType ─────────► LinkType ────► ObjectType
EventType ──────► ObjectType (event references a thing)
ObjectType ──────► EventType (thing was involved in event)A LinkType connects instances of two types, not the types themselves. To say "User and Device can be linked with OWNS," declare LinkType(name = "OWNS", from = ObjectType("User"), to = ObjectType("Device")). Each Link instance you create is one row in that edge table.
A simple rule of thumb
- If you would model it as a row in Postgres →
ObjectType. - If you would model it as an append-only event log table →
EventType. - If you would model it as a foreign key or join table →
LinkType(or, if the FK column isIdentity-typed, just declare the property asIdentityand skip the explicit edge — the graph forms itself).
PostgreSQL → RelataDB
A textbook Postgres schema and its RelataDB equivalent.
Side-by-side
-- Postgres
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
phone TEXT,
country TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE logins (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
ip INET,
user_agent TEXT,
occurred_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX logins_user_idx ON logins(user_id, occurred_at DESC);# RelataDB — schema-as-code, declarative
[User]
kind = "ObjectType"
properties =
email = { type = "Identity", canonical = "Email", unique = true }
phone = { type = "Identity", canonical = "Msisdn" }
country = { type = "String" }
cluster_key = "email"
[Login]
kind = "EventType"
properties =
user_id = { type = "Identity", link_to = "User" }
ip = { type = "Identity", canonical = "IPv4" }
user_agent = { type = "String" }
cluster_key = "(user_id, occurred_at)"
[UserLoggedIn]
kind = "LinkType"
from = "User"
to = "Login"What changed
| Postgres thing | RelataDB thing | Why |
|---|---|---|
BIGSERIAL PRIMARY KEY | Server-minted ObjectId (RowId) | You don't supply IDs on insert; the server hands them back. |
TEXT UNIQUE NOT NULL for email | { type = "Identity", canonical = "Email", unique = true } | Validates RFC 5322, lowercases, encodes to bytes, and writes to the universal IdentityIndex. |
TEXT for phone | { type = "Identity", canonical = "Msisdn" } | +971 50 123 4567 and 971501234567 are now the same 8-byte value — no TRIM/REPLACE/LOWER in your queries. |
INET for ip | { type = "Identity", canonical = "IPv4" } | Same shape, plus free lookup via LOOKUP_IDENTITY(...). |
TIMESTAMPTZ DEFAULT now() | valid_from / system_from (i64 ns UTC) | You get two axes of time for free — see "The four automatic columns" below. |
REFERENCES users(id) | link_to = "User" on an Identity property | Validates that the target exists; the engine also maintains an indexed edge. |
CREATE INDEX | None | Identity-typed properties already write to IdentityIndex; cluster_key already sorts Parquet; you only declare extra indexes when you actually need a non-default access path. |
Query parity
-- Postgres
SELECT u.email, COUNT(*) AS login_count
FROM users u
JOIN logins l ON l.user_id = u.id
WHERE l.occurred_at >= now() - INTERVAL '7 days'
GROUP BY u.email
ORDER BY login_count DESC
LIMIT 10;-- RelataDB — same shape, plus audit by default
PURPOSE 'analytics'
SELECT u.email, COUNT(*) AS login_count
FROM User u
JOIN Login l ON l.user_id = u.email -- identity-typed join
WHERE l.occurred_at >= now() - INTERVAL '7 days'
GROUP BY u.email
ORDER BY login_count DESC
LIMIT 10;The differences: drop-in SELECT syntax works the same, Purpose declares why you're reading (audited), and the join resolves through the IdentityIndex so +971 50 … and 97150… are the same row.
Point your existing psql / psycopg2 / pgvector client at RelataDB's port 5433 (password = your RELATA_BEARER_TOKEN). Every SELECT / INSERT / UPDATE / DELETE works — including your pgvector KNN operators (<=>, <->, <#>). See Compatibility & Doors. The RelataDB SQL is additive, not a fork.
MongoDB → RelataDB
A typical Mongo schema and its Relata equivalent.
Side-by-side
// MongoDB
db.users.insertOne({
_id: ObjectId("..."),
email: "alice@example.com",
phone: "+1 415-555-0100",
profile: { bio: "...", avatar_url: "..." },
addresses: [
{ kind: "home", line1: "...", city: "...", country: "US" }
],
tags: ["vip", "founder"]
});
db.events.insertOne({
type: "page_view",
user_id: ObjectId("..."),
url: "/pricing",
ts: new Date()
});# RelataDB
[User]
kind = "ObjectType"
properties =
email = { type = "Identity", canonical = "Email", unique = true }
phone = { type = "Identity", canonical = "Msisdn" }
bio = { type = "String", indexed = "bm25" }
avatar = { type = "BlobRef" }
[Address]
kind = "ObjectType"
properties =
user_email = { type = "Identity", canonical = "Email", link_to = "User" }
kind = { type = "String" }
line1 = { type = "String" }
city = { type = "String" }
country = { type = "String" }
[UserTag]
kind = "ObjectType"
properties =
user_email = { type = "Identity", canonical = "Email", link_to = "User" }
tag = { type = "String" }
[PageView]
kind = "EventType"
properties =
user_email = { type = "Identity", canonical = "Email", link_to = "User" }
url = { type = "String" }What changed
| Mongo thing | RelataDB thing | Why |
|---|---|---|
_id: ObjectId("…") | Server-minted ObjectId | You don't supply IDs; the server returns them after insert. |
| Top-level scalar fields | Typed properties with canonical kinds where applicable | The same email written by five feeds becomes one byte representation. |
Embedded sub-doc (profile.bio) | Separate ObjectType (e.g. Profile) reachable via LinkType, or keep it nested as a typed object property if you don't query it independently | Pick the separate type when you'll filter/sort by its fields. |
Array of sub-documents (addresses[]) | Separate ObjectType (e.g. Address) + LinkType | Querying "everyone in city X" needs indexed city, which arrays don't give you. |
Array of scalars (tags[]) | Separate ObjectType (e.g. UserTag) — usually faster than you'd think | Or a Set<CanonicalKind> if your values are all canonical. |
ts: new Date() | occurred_at (i64 ns UTC) | Plus the four automatic time columns below — see "The four automatic behaviors." |
The MongoDB door on port 27017 speaks SCRAM-SHA-256 and accepts insertOne / find / update / delete. Documents land as MongoDocument rows queryable from SQL. See MongoDB door.
Other databases — quick translations
| Source | Mapping |
|---|---|
| DynamoDB | Table → ObjectType. Partition key → cluster_key. Sort key → second component of cluster_key. GSI → declared secondary index on a typed property (vector, BM25, identity, range). Streams → EventType rows with the same cluster_key. |
| Redis | SET key value → one KvEntry row. HSET hash field value → KvEntry rows with key hash:field. Pub/Sub → PubSubMessage rows queryable from SQL. Use the Redis door on 6379 — your redis-cli works unchanged. |
| Cassandra / ScyllaDB | Keyspace → namespace. Table → ObjectType (or EventType for time-series-like data). Partition key → cluster_key. Clustering columns → declared secondary sort. Tombstones → system_to = i64::MAX and a RowId allocation, not a hard delete. |
| Neo4j | Label → ObjectType. Node properties → typed properties. Relationship type → LinkType. Pattern (a)-[r:KNOWS]->(b) → MATCH (a:User)-[r:KNOWS]->(b:User) RETURN … over the Bolt / HTTP Cypher door. |
| Snowflake / BigQuery / Databricks | Table → ObjectType. Streaming ingest → EventType. Identity resolution (the part of the warehouse ETL that says "this customer = that customer") → built into the engine — declare properties as Identity and the graph forms itself. |
| Elasticsearch / OpenSearch | Index → ObjectType with one or more indexed = "bm25" properties. Document _id → server-minted ObjectId. BM25 query → WHERE MATCH(text, 'query'). |
| Pinecone / Weaviate / Qdrant | Collection → ObjectType with one [N]f32 property declared as the embedding column. ANN query → ORDER BY embedding <=> '[…]' (pgvector door) or HYBRID_SEARCH for BM25 + vector in one shot. |
The doors above aren't exclusive — write to one, read from another, in the same app. A common pattern: existing Mongo service writes via the Mongo door; new analytics features read via SQL or the Python SDK; admin / governance features use HTTP REST. Same rows, same ACL, same audit log. See Cross-Door Data Visibility.
The four automatic behaviors every row gets
This is the part most-missed by developers new to Relata. Every row, in every type, every protocol door, gets these four things automatically. You don't enable them. They're the engine's defaults.
1. Bi-temporal timestamps (4 of them)
Every row carries four i64 nanosecond UTC timestamps:
| Timestamp | Axis | Question it answers |
|---|---|---|
valid_from | Valid time | When did this become true in the real world? |
valid_to | Valid time | When did this stop being true? (i64::MAX = currently true) |
system_from | System time | When did the database first record this version? |
system_to | System time | When was this version superseded? (i64::MAX = current) |
The Postgres / Mongo equivalent takes deliberate work — created_at / updated_at columns, SCD Type 2 history tables, or "audit log" tables you maintain yourself. In RelataDB it's the row shape; there is no opt-out. See Bi-Temporal.
-- What was Alice's email on 1 March 2024?
PURPOSE 'audit' SELECT email FROM User
WHERE email = 'alice@example.com'
AS OF '2024-03-01T00:00:00Z';
-- What did we *believe* on 1 March 2024 (system-time)?
PURPOSE 'audit' SELECT email FROM User
AS OF SYSTEM TIME '2024-03-01T00:00:00Z'
WHERE email = 'alice@example.com';2. Provenance — every write has a paper trail
Every write produces a hash-chained PROV-O assertion. The row stores a 32-byte prov reference; the full assertion (source, method, confidence, derived-from chain) is queryable. The chain is tamper-evident — relata doctor walks it offline; GET /audit/count checks it online. In Postgres/Mongo you build this with a audit_log table, triggers, and a prayer. See Provenance.
3. Identity resolution — same entity, different spellings
Any property declared type = "Identity" validates its value (76 canonical kinds — phone, email, IBAN, IMEI, IPv4, …), encodes it to a canonical byte representation, and writes it into a universal IdentityIndex MV. The same phone number written by three feeds lands as one byte sequence — joins happen at scan time, no LOWER/TRIM/REPLACE in your predicates. Two records that share a validated identity automatically become neighbors in the graph plane.
In Postgres/Mongo this is the "we'll normalize in the application layer" tax that everyone pays and nobody likes. See Identity.
4. Cell-level ACL in the scan predicate
If you declared governance policy, the engine compiles it into the bitmap scan predicate — not as a post-filter, not in the app, not "best effort." A query without the right PURPOSE returns zero rows for the protected columns; the planner proves it via EXPLAIN POLICY. See Governance and Access Control.
In Postgres/Mongo this is row-level security (RLS) + view-level masks + application-layer checks; RelataDB unifies them into one path the planner walks every query.
If you have built any of these in your current stack — SCD2 history tables, audit log + triggers, an ETL pipeline that normalizes and dedups identifiers, a row-level security policy + view layer — you can delete them. RelataDB does all four by default, in the storage engine, in the query planner. The savings on cognitive load and code surface are usually larger than the raw license delta.
The graph plane — automatic and queryable
In Postgres you write a join table; in Mongo you write $lookup; in Neo4j you write (a)-[r:KNOWS]->(b). In Relata you declare a LinkType once and the edges exist for every pair of instances you connect — but you also get a free second graph: every pair of rows that share an Identity-typed property is automatically connected. This is the substrate for RESOLVE_IDENTITY, IDENTITY_CLUSTER, and SAME_IDENTITY.
-- Are these two identifiers the same person?
SELECT * FROM SAME_IDENTITY('+971501234567', 'alice@example.com');
-- Walk every path between two entities, up to 4 hops
SELECT * FROM PATHS_BETWEEN('alice@example.com', 'bob@example.com', MAX_HOPS => 4);
-- All phone numbers, emails, IBANs known to be linked to this entity
SELECT * FROM IDENTITY_CLUSTER('alice@example.com');You can also keep using Cypher (MATCH (n)-[r:KNOWS]->(m) RETURN n, r, m) over the Bolt / HTTP door — see Neo4j door. The graph forms itself underneath.
Worked example — convert a real schema end-to-end
A "support ticket" schema you might build in Postgres or Mongo, and the RelataDB version that keeps the same shape but adds time travel, provenance, identity resolution, and cell-level ACL by default.
Source schema (Postgres + Mongo hybrid)
-- Postgres
CREATE TABLE tickets (
id BIGSERIAL PRIMARY KEY,
subject TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('open', 'pending', 'closed')),
customer_id BIGINT NOT NULL REFERENCES customers(id),
assignee_id BIGINT REFERENCES agents(id),
priority TEXT NOT NULL DEFAULT 'normal',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE ticket_comments (
id BIGSERIAL PRIMARY KEY,
ticket_id BIGINT NOT NULL REFERENCES tickets(id),
author_id BIGINT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE ticket_attachments (
id BIGSERIAL PRIMARY KEY,
ticket_id BIGINT NOT NULL REFERENCES tickets(id),
filename TEXT NOT NULL,
bytes BYTEA,
sha256 BYTEA
);RelataDB equivalent
# Ontology (schema-as-code, versioned in git)
[Customer]
kind = "ObjectType"
properties =
email = { type = "Identity", canonical = "Email", unique = true }
phone = { type = "Identity", canonical = "Msisdn" }
name = { type = "String" }
[Agent]
kind = "ObjectType"
properties =
email = { type = "Identity", canonical = "Email", unique = true }
team = { type = "String" }
[Ticket]
kind = "ObjectType"
properties =
subject = { type = "String", indexed = "bm25" }
status = { type = "String" }
priority = { type = "String" }
state_machine = { field = "status",
transitions = [
{ from = "open", to = "pending" },
{ from = "pending", to = "closed" },
{ from = "closed", to = "open" }
] }
[Comment]
kind = "EventType"
properties =
ticket_id = { type = "Identity", link_to = "Ticket" }
author_id = { type = "Identity", link_to = "Agent" }
body = { type = "String" }
[Attachment]
kind = "ObjectType"
properties =
ticket_id = { type = "Identity", link_to = "Ticket" }
filename = { type = "String" }
sha256 = { type = "Identity", canonical = "Hash_SHA256" }
bytes = { type = "BlobRef" }
[OpenedTicket]
kind = "LinkType"
from = "Customer"
to = "Ticket"
[AssignedTicket]
kind = "LinkType"
from = "Agent"
to = "Ticket"What you gained, for free
| Feature | Source-system cost | RelataDB cost |
|---|---|---|
| Time travel on tickets ("what was the status on 1 March?") | SCD2 history table + triggers | AS OF '2024-03-01T00:00:00Z' |
| Audit chain ("who set this to closed, when, from what value?") | Audit log table + triggers + a way to detect tampering | Built-in PROV-O provenance + hash-chained manifests |
| Dedup customer across web + mobile + email-support | ETL pipeline that LOWER/TRIM/REPLACEs and a dedupe job | Identity-typed properties |
Status can't skip from open to closed | Postgres CHECK constraint | state_machine declaration on the type |
| ACL: agent in team X sees only their team's tickets | RLS policy + view layer + app-layer checks | PURPOSE + cell-level ACL compiled into the scan |
| Hybrid search: "tickets mentioning refund" | Elasticsearch sidecar + ETL | WHERE MATCH(subject, 'refund') on the BM25-indexed property |
| Full-text comments, ranked | Search index + sync | Same MATCH on the comment body |
What you'd write in SQL, end-to-end
-- Open tickets for a customer, hybrid-ranked
PURPOSE 'support'
SELECT t.id, t.subject, t.status, t.priority, t.valid_from
FROM Ticket t
JOIN OpenedTicket ot ON ot.to = t.id
WHERE ot.from = RESOLVE_IDENTITY('alice@example.com')
AND t.status = 'open'
AND MATCH(t.subject, 'refund OR billing')
ORDER BY rank DESC
LIMIT 25;
-- The full audit trail for one ticket — what changed, when, by whom
PURPOSE 'audit'
SELECT * FROM Ticket
WHERE id = 'ticket-123'
ORDER BY system_from DESC
WITH PROVENANCE;
-- "All comments and attachments, ever, for tickets involving this email"
PURPOSE 'support'
SELECT c.*, a.filename
FROM Comment c
LEFT JOIN Attachment a ON a.ticket_id = c.ticket_id
WHERE c.ticket_id IN (
SELECT id FROM Ticket WHERE customer_email = 'alice@example.com'
)
ORDER BY c.occurred_at DESC;Common mistakes when porting a mental model
| You might think… | Reality |
|---|---|
| "I'll use one type per row and embed everything." | Use a separate ObjectType (reachable via LinkType) whenever you'll query by the embedded fields. Embedded objects are first-class properties, but you can't filter on a nested field without an index. |
"I'll use BIGSERIAL IDs and supply them on insert." | IDs are server-minted RowIds (monotonic counters). Insert without an ID; the response carries the new ObjectId. |
"I'll UPDATE to fix a wrong value." | Don't. Insert a new row with the corrected valid_from and valid_to. The store maintains system-time versioning automatically; your old version stays queryable. (UPDATE works for in-place edits, but you lose the history.) |
"I'll add created_at and updated_at myself." | You get system_from / system_to (database clock) and valid_from / valid_to (real-world clock) by default. Don't add timestamps manually. |
"I'll LOWER/REPLACE/TRIM in my WHERE." | Declare the property Identity-typed. The canonical encoding normalizes for you, and the value participates in IdentityIndex. |
| "I'll add an audit-log table and triggers." | Already done, per-row, hash-chained. Add a PURPOSE to your queries; the audit log is automatic. |
"I'll add BIGINT REFERENCES and rely on FK constraints." | Declare link_to = "OtherType" on an Identity property; the engine validates existence and maintains an indexed edge. Foreign-key column types should be Identity so the join uses IdentityIndex. |
"I'll use LIKE '%foo%' for search." | Declare the property indexed = "bm25", then WHERE MATCH(col, 'foo PHRASE'). Faster, ranked, supports phrase/fuzzy/stemmed/boolean. |
| "I'll put embeddings in a separate vector DB." | Declare the property as [N]f32. ANN search is built in (custom HNSW + DiskANN warm tier) and pgvector KNN operators (<=>, <->, <#>) work unchanged on the pgwire door. |
| "I'll do ETL to populate a data warehouse for analytics." | Skip the warehouse — query the governed store directly via SQL, Arrow Flight, or ClickHouse door. The MV refresh jobs handle aggregation incrementally. |
Mental-model cheatsheet — keep this open
Postgres → RelataDB
───────── ──────────
CREATE TABLE → [Type] declaration in TOML or Rust
BIGSERIAL → server-minted ObjectId
TEXT → String
TEXT (email) → Identity (canonical = "Email")
TIMESTAMPTZ → i64 ns UTC (valid_from / system_from)
FOREIGN KEY → LinkType, or Identity property with link_to
CHECK → state_machine declaration
CREATE INDEX → indexed = "bm25" | "hnsw" | "range"
audit_log tbl → automatic PROV-O provenance per row
RLS policy → cell-level ACL compiled into scan
MongoDB → RelataDB
───────── ──────────
collection → ObjectType / EventType
_id → server-minted ObjectId
field → typed property
embed → typed nested property OR separate ObjectType + LinkType
array → separate ObjectType with link_to
$lookup → LinkType traversal OR IDENTITY_CLUSTER
text index → indexed = "bm25" on a propertySee also
- Data Model — the formal ontology, identifier types, bi-temporal row shape, and canonical types
- Ontology & Schema — schema-as-code, computed columns, state machines, git-branched schema evolution
- Bi-Temporal —
AS OFandAS OF SYSTEM TIMEquery semantics - Identity —
Identity,IdentityIndex,RESOLVE_IDENTITY,SAME_IDENTITY - Provenance — hash-chained PROV-O assertions and the audit log
- Governance — cell-level ACL and
PURPOSE - Compatibility & Doors — keep your existing Postgres / Mongo / Redis / Neo4j / S3 / ClickHouse client
- Relata vs Others — the broader map vs Postgres / Neo4j / Pinecone / lakehouse / agent-memory tools
- Quickstart — pick your SDK (Python / TypeScript / Go) and run your first query