Database schema
Ten tables in PostgreSQL with pgvector. Defined in
apps/api/src/db/schema.ts, applied by migrations.
projects ──┬── api_keys │ ├── threads ──── messages │ │ │ └──── episodes ──── facts │ ├── scheduled_episodes │ └── entities (referenced by name, not FK)
users ──── sessions (dashboard login only)Tenancy is (project_id, dataset) on every memory table.
projects
Section titled “projects”id uuid PRIMARY KEY DEFAULT gen_random_uuid()name text NOT NULLdescription textsettings jsonb -- partial ProjectSettings, merged with defaultscreated_at timestamp NOT NULL DEFAULT now()settings stores only what was overridden, so new defaults in a future
version reach projects that never changed them.
api_keys
Section titled “api_keys”id uuid PRIMARY KEYname text NOT NULLkey text NOT NULL UNIQUE -- SHA-256 hash, never plaintextkey_preview text NOT NULL -- "ms_3f9a4c…0161"project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADEcreated_at timestamp NOT NULL DEFAULT now()last_used_at timestamprevoked_at timestamplast_used_at is written on every authenticated request, one UPDATE per
API call.
users / sessions
Section titled “users / sessions”Dashboard operators. Not end-user data.
-- usersid uuid PRIMARY KEYusername text NOT NULL UNIQUEpassword_hash text NOT NULL -- scrypt$N$r$p$salt$hex (legacy: salt:hex)created_at timestamptz NOT NULL DEFAULT now()updated_at timestamptz NOT NULL DEFAULT now()
-- sessionsid uuid PRIMARY KEYuser_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADEtoken text NOT NULL UNIQUE -- SHA-256 hashcreated_at timestamptz NOT NULL DEFAULT now()expires_at timestamptz NOT NULL -- created_at + 7 dayslast_used_at timestamptzrevoked_at timestamptz
INDEX sessions_user_idx (user_id)threads
Section titled “threads”id uuid PRIMARY KEYdataset text NOT NULLproject_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADEtags text[] NOT NULL DEFAULT '{}'metadata jsonbcreated_at timestamptz NOT NULL DEFAULT now()updated_at timestamptz NOT NULL DEFAULT now()last_activity_at timestamptz NOT NULL DEFAULT now()auto_compact_threshold integer -- NULL disables compactionepisodic_settings jsonb -- per-thread overridesemantic_settings jsonb -- supported internally, no APIlast_compacted_at timestamptzlast_compacted_sequence integer NOT NULL DEFAULT 0
INDEX threads_activity_idx (last_activity_at)INDEX threads_project_idx (project_id)There is no
message_count. It was a denormalised counter that could drift and was dropped in migration 0010; counts are derived with a correlated subquery.
messages
Section titled “messages”id uuid PRIMARY KEYthread_id uuid NOT NULL REFERENCES threads(id) ON DELETE CASCADErole message_role NOT NULL -- user | assistant | system | toolcontent text NOT NULLsequence_number integer NOT NULLtokens jsonb -- { input?, output?, total? }model textlatency_ms integermetadata jsonb -- { stopReason?, agentName? }compacted_at timestamptz -- set when folded into a summarycreated_at timestamptz NOT NULL DEFAULT now()
UNIQUE INDEX messages_thread_seq_idx (thread_id, sequence_number)INDEX messages_thread_time_idx (thread_id, created_at DESC)INDEX messages_thread_compacted_idx(thread_id, compacted_at)sequence_number is assigned inside a FOR UPDATE transaction on the thread
row. It is the pagination cursor and the compaction watermark.
Summary rows carry metadata.type = 'compact_summary' with a compactedRange.
The column was renamed from
token_counttotokensin migration 0010.
episodes
Section titled “episodes”id uuid PRIMARY KEYthread_id uuid REFERENCES threads(id)dataset text NOT NULLproject_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADEstatus episode_status NOT NULL DEFAULT 'pending'semantic_status semantic_status NOT NULL DEFAULT 'pending'summary textkey_learnings jsonb -- string[]embedding vector(768)message_count integer NOT NULL DEFAULT 0token_count integerstarted_at timestamptzended_at timestamptzprocessing_started_at timestamptzprocessing_completed_at timestamptzerror textretry_count integer NOT NULL DEFAULT 0semantic_retry_count integer NOT NULL DEFAULT 0start_sequence integer -- inclusive message windowend_sequence integermetadata jsonbcreated_at timestamptz NOT NULL DEFAULT now()updated_at timestamptz NOT NULL DEFAULT now()
INDEX episodes_dataset_project_status_idx (dataset, project_id, status)INDEX episodes_dataset_created_idx (dataset, created_at)INDEX episodes_thread_idx (thread_id)INDEX episodes_status_created_idx (status, created_at)INDEX episodes_embedding_idx ivfflat (embedding vector_cosine_ops) WITH (lists=100) WHERE embedding IS NOT NULLTwo independent status columns.
episode_status : pending | processing | completed | failed | deleted | archivedsemantic_status : pending | processing | completed | failed | skippedstatus tracks summarisation; semantic_status drives fact extraction.
semantic_status is not exposed on any endpoint, query it directly.
[start_sequence, end_sequence] is the message window extraction reads. NULL
on legacy rows, which fall back to the whole un-compacted thread.
scheduled_episodes
Section titled “scheduled_episodes”A one-row-per-thread timer.
thread_id uuid PRIMARY KEY REFERENCES threads(id) ON DELETE CASCADEproject_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADEfire_at timestamptz NOT NULL
INDEX scheduled_episodes_fire_at_idx (fire_at)addMessage upserts fire_at = now() + autoEpisodeIntervalMs. Because the
primary key is thread_id, a burst of messages keeps pushing the deadline out
rather than queuing many episodes. Creating a thread sets every sibling’s
fire_at (same project and dataset) to LEAST(fire_at, now() + 5 min).
Claimed with DELETE … RETURNING, so each row fires exactly once.
The core table.
id uuid PRIMARY KEYdataset text NOT NULLproject_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADEsubject text NOT NULL -- always 'user'predicate text NOT NULLobject text NOT NULLobject_is_entity boolean NOT NULL DEFAULT falseconfidence real NOT NULL DEFAULT 1 -- model self-rated, 0–1source_quote text -- verbatim provenanceepisode_id uuid REFERENCES episodes(id) ON DELETE SET NULLvalid_at timestamptz NOT NULL DEFAULT now() -- valid timevalid_until timestamptz -- valid timeinvalid_at timestamptz -- belief timeembedding vector(768)created_at timestamptz NOT NULL DEFAULT now() -- belief timeupdated_at timestamptz NOT NULL DEFAULT now()One table holds both literal facts (object_is_entity = false) and entity
relationships (true), which leaves multi-hop traversal possible later without a
second store.
Indexes
Section titled “Indexes”INDEX facts_dataset_project_invalid_idx (dataset, project_id, invalid_at)INDEX facts_dataset_project_subject_idx (dataset, project_id, subject)INDEX facts_dataset_project_object_idx (dataset, project_id, object)INDEX facts_episode_idx (episode_id)
-- no-query fallbackINDEX facts_dataset_project_recency_idx (dataset, project_id, valid_at) WHERE invalid_at IS NULL
-- duplicate backstop across concurrent extraction jobsUNIQUE INDEX facts_live_exact_idx (dataset, project_id, subject, predicate, object, coalesce(valid_until, 'infinity'::timestamptz)) WHERE invalid_at IS NULL
INDEX facts_embedding_idx ivfflat (embedding vector_cosine_ops) WITH (lists=100) WHERE embedding IS NOT NULL
INDEX facts_tsv_idx gin ( to_tsvector('english', coalesce(subject,'') || ' ' || coalesce(predicate,'') || ' ' || coalesce(object,'')))valid_until is part of the unique key so an expired fact does not block
re-asserting the same claim.
facts_tsv_idx’s expression must stay byte-identical to the one the query builds, or the planner silently stops using it.
Liveness
Section titled “Liveness”invalid_at IS NULLAND valid_at <= now()AND (valid_until IS NULL OR valid_until > now())now() is not immutable, so the valid_until clause cannot be pushed into the
partial indexes, they are keyed on invalid_at IS NULL and cover a superset of
live rows.
Point-in-time:
created_at <= $asOfAND valid_at <= $asOfAND (valid_until IS NULL OR valid_until > $asOf)AND (invalid_at IS NULL OR invalid_at > $asOf)entities
Section titled “entities”id uuid PRIMARY KEYdataset text NOT NULLproject_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADEname text NOT NULL -- lower-cased canonical formtype text NOT NULL -- PERSON | ORG | PLACE | …embedding vector(768)created_at timestamptz NOT NULL DEFAULT now()updated_at timestamptz NOT NULL DEFAULT now()
UNIQUE INDEX entities_dataset_project_name_idx (dataset, project_id, name)INDEX entities_embedding_idx ivfflat (embedding vector_cosine_ops) WITH (lists=100) WHERE embedding IS NOT NULLFacts reference entities by name, not by foreign key. Deliberate: it keeps
the write path free of lookups and makes the anchor query a plain IN (…).
The cost is no referential integrity, a renamed entity would orphan its facts,
which is why entity names are never updated in place.
Vector storage
Section titled “Vector storage”- pgvector, 768 dimensions, from
gemini-embedding-001. - Cosine distance (
<=>); similarity is1 - distance. - ~3 KB per embedding. They dominate database size and compress poorly.
The IVFFlat indexes were created on empty tables, so their centroids are degenerate. On a populated instance,
REINDEXor switch to HNSW, see Migrations.
Cascade behaviour
Section titled “Cascade behaviour”| Deleting | Removes |
|---|---|
projects |
api_keys, threads → messages, episodes, facts, entities, scheduled_episodes |
threads |
messages, scheduled_episodes. Not episodes (nullable FK), not facts |
episodes |
Nothing, facts.episode_id is ON DELETE SET NULL |
users |
sessions |
Facts and entities are scoped to the dataset, not the thread, and outlive both. Deleting a conversation does not delete what was learned from it. See Privacy and data deletion.
Useful queries
Section titled “Useful queries”-- rows per table for a datasetSELECT 'threads' t, count(*) FROM threads WHERE dataset='user_42'UNION ALL SELECT 'facts', count(*) FROM facts WHERE dataset='user_42'UNION ALL SELECT 'entities', count(*) FROM entities WHERE dataset='user_42'UNION ALL SELECT 'episodes', count(*) FROM episodes WHERE dataset='user_42';
-- live factsSELECT subject, predicate, object, valid_atFROM factsWHERE dataset='user_42' AND invalid_at IS NULL AND valid_at <= now() AND (valid_until IS NULL OR valid_until > now())ORDER BY valid_at DESC;
-- pipeline healthSELECT status, semantic_status, count(*) FROM episodes GROUP BY 1,2 ORDER BY 3 DESC;
-- largest datasets, for the extraction memory ceilingSELECT dataset, count(*) AS live_factsFROM facts WHERE invalid_at IS NULLGROUP BY 1 ORDER BY 2 DESC LIMIT 20;
-- table sizesSELECT relname, pg_size_pretty(pg_total_relation_size(relid)) AS sizeFROM pg_catalog.pg_statio_user_tables ORDER BY pg_total_relation_size(relid) DESC;