Skip to content

Database Schema

All tables live in a single Postgres public schema for Alpha. Production will move to per-bounded-context schemas once Supabase Auth is wired and the auth.users table linkage is owned.

ORM: Prisma 5 — schema at apps/api/prisma/schema.prismaDB: Supabase Postgres (pg 16) + pgvector + RLS


Identity Context

users

Primary user record. One row per registered account.

ColumnTypeNotes
idUUID PKInternal identifier
emailtext UNIQUELogin email
usernametext UNIQUEOptional @handle
password_hashtextbcrypt
first_nametext
last_nametext
date_of_birthdate
countrytext
citytext
sextext?Optional
phonetext?Optional
abouttext?Bio
profile_pic_urltext?Cloudflare R2 URL
roletextuser | moderator | admin (default user)
banned_attimestamptz?Non-null = banned
invite_code_idUUID FK → invite_codesCode used to register
inviter_user_idUUID?Denormalised inviter ID
created_attimestamptz
updated_attimestamptz

invite_codes

Invite tokens in REG-XXXXXX format.

ColumnTypeNotes
idUUID PK
codetext UNIQUEREG-[A-Z0-9]{6}
statustextactive | reserved | used | expired | revoked
generated_by_user_idUUID?Owner
reserved_by_session_idtext?Session that reserved it
reserved_untiltimestamptz?TTL 30 min
used_by_user_idUUID?Who registered
used_attimestamptz?
expires_attimestamptzCode validity window (30 days)
created_attimestamptz

Indexes: status, generated_by_user_id


personas

Sub-identities per user, max 5. One is is_default = true.

ColumnTypeNotes
idUUID PK
user_idUUID FK → users CASCADE
namevarchar(40)
aboutvarchar(280)?
accentvarchar(20)?Token: ink | gold | sage | azure
profile_pic_urltext?
is_defaultbool
created_attimestamptz
updated_attimestamptz

Unique: (user_id, name). Index: user_id


password_resets

ColumnTypeNotes
tokentext PKOpaque short token
user_idUUID FK → users
expires_attimestamptz
used_attimestamptz?
created_attimestamptz

user_blocks / user_mutes / user_follows

All three use composite PK (actor_id, target_id).

ColumnType
blocker_id / muter_id / follower_idUUID FK → users
blocked_id / muted_id / following_idUUID FK → users
created_attimestamptz

Note: user_follows has a DB CHECK constraint preventing self-follows.


push_tokens

ColumnTypeNotes
idUUID PK
user_idUUID FK → users
tokentext UNIQUEExponentPushToken[...]
platformtextios | android | web
device_nametext?
last_seen_attimestamptzUpdated on each notification attempt

muted_keywords

ColumnTypeNotes
idUUID PK
user_idUUID FK → users
termtextLower-cased; case-insensitive LIKE match
created_attimestamptz

Unique: (user_id, term)


Interests Context

interests_catalog

The full interest hierarchy.

ColumnTypeNotes
idUUID PK
slugtext UNIQUEURL-safe identifier
nametextDisplay name
pathtextDotted ltree-style: philosophy.existentialism
parent_idUUID? FK → selfNull for L1 roots
levelint1=category, 2=sub-interest, 3=deep-interest
accent_colortext?Palette key: ink|gold|sage|coral|cyan|plum
is_customboolFalse for catalog entries
created_attimestamptz

Indexes: parent_id, level


user_interests

Per-user selections with engagement depth.

ColumnTypeNotes
user_idUUID FK → users CASCADE
interest_idUUID FK → interests_catalog CASCADE
depthint1–5: Curious → Reading → Practicing → Deep → Lifework
expertiseint1–5: self-rated expertise, distinct from depth
descriptiontext?Personal note
created_attimestamptz
updated_attimestamptz

PK: (user_id, interest_id). Indexes: user_id, interest_id


Social Context

circles

ColumnTypeNotes
idUUID PK
owner_idUUID FK → users CASCADE
kindtextgeneral | family | work | inner | custom
nametext
accenttext?Color token
created_at / updated_attimestamptz

Unique: (owner_id, kind, name). Index: owner_id


circle_members

ColumnTypeNotes
circle_idUUID FK → circles CASCADE
member_idUUID FK → users CASCADE
bond_leveltextinner-circle | acquaintance
notetext?
created_attimestamptz

PK: (circle_id, member_id)


circle_invitations

Pending invite rows (created on invite, deleted/updated on respond).

ColumnTypeNotes
idUUID PK
circle_idUUID FK
inviter_idUUID FK
invitee_idUUID FK
bond_leveltext
statustextpending | accepted | declined | revoked
messagetext?
created_attimestamptz
responded_attimestamptz?

Unique: (circle_id, invitee_id, status) — one open invite per pair.


connection_intents

One active row per user (upserted on update).

ColumnTypeNotes
user_idUUID PK FK → users
intenttextnetworking | friends | romantic | mentor | mentee | collab
notetext?Discovery card text
is_activeboolFalse = hidden from discovery
updated_at / created_attimestamptz

Posts Context

posts

ColumnTypeNotes
idUUID PK
author_idUUID FK → users CASCADE
interest_idUUID? FK → interestsPrimary interest tag
formattextnote|essay|photo|video|quote|thread|repost
titletext
excerpttext?
bodytext?
media_urltext?R2 URL
quoted_post_idUUID? FK → postsFor quote/repost
reactionsintMaterialised counter
repliesintMaterialised counter
sharesintMaterialised counter
bookmarksintMaterialised counter
viewsintMaterialised counter (fire-and-forget)
visibilitytextpublic | circles | inner
hidden_attimestamptz?Moderator hide
publish_attimestamptz?Scheduled publish
scheduled_emitted_attimestamptz?Prevents double-emit on restart
deleted_attimestamptz?Author soft-delete
persona_idUUID?Optional persona attribution
depthint0–5 content depth (default 0)
boost_scorefloatMaterialised boost multiplier
boost_expires_attimestamptz?
pinned_attimestamptz?Profile pin, max 3 per user
is_journalismbool#journalism tag (rep gate ≥ 20)
created_at / updated_attimestamptz

Indexes: author_id, (interest_id, created_at), created_at, quoted_post_id, persona_id


post_replies

ColumnTypeNotes
idUUID PK
post_idUUID FK → posts CASCADE
author_idUUID FK → users CASCADE
bodytext
kindtextreply|reaction|criticism|contribution
hidden_attimestamptz?Moderator hide
created_attimestamptz

post_reactions

ColumnTypeNotes
post_idUUID FK
user_idUUID FK
kindtextheart | spark | bookmark — only heart counted in reactions counter
created_attimestamptz

PK: (post_id, user_id, kind)


post_interests (junction)

ColumnType
post_idUUID FK → posts CASCADE
interest_idUUID FK → interests_catalog CASCADE
is_primarybool

post_co_authors

ColumnTypeNotes
idUUID PK
post_idUUID FK
user_idUUID FK
split_shareint0–100; server enforces sum of accepted ≤ 90
statusvarchar(20)pending | accepted | declined
invited_attimestamptz
responded_attimestamptz?

post_series

ColumnType
idUUID PK
author_idUUID FK → users
titlevarchar(120)
descriptionvarchar(500)?
created_at / updated_attimestamptz

post_series_items

ColumnType
series_idUUID FK CASCADE
post_idUUID FK CASCADE
positionint (1-based)

Unique: (series_id, position)

series_follows

ColumnType
user_idUUID FK → users
series_idUUID FK → post_series
created_attimestamptz

post_boosts

ColumnTypeNotes
idUUID PK
post_idUUID
user_idUUID
tierint1=×1.5/24h, 2=×2/48h
amountintMana spent
expires_attimestamptz
created_attimestamptzAppend-only

post_bookmarks / post_mutes

Both composite PK (post_id, user_id).


Media

media_assets

ColumnTypeNotes
idUUID PK
owner_idUUID FK → users
buckettextR2 bucket name
keytextObject key
mime_typetext
size_bytesint
width / heightint?Stamped lazily
duration_msint?For video/audio
statustextpending until client confirms; committed after
created_at / committed_attimestamptz

Unique: (bucket, key)


Reputation Context

reputation_events

Append-only ledger. Never UPDATE.

ColumnTypeNotes
idUUID PK
user_idUUID
interest_idUUID?Null = platform-level nudge
deltaintPositive or negative
reasontexte.g. posts.created, peer_vote
contextjsonb?Extra metadata
created_attimestamptz

Indexes: user_id, (user_id, interest_id), created_at


reputation_snapshots

Materialised SUM per (user, interest).

ColumnType
user_idUUID
interest_idUUID
valueint (default 0)
updated_attimestamptz

PK: (user_id, interest_id)


reputation_votes

ColumnTypeNotes
idUUID PK
voter_idUUID
target_user_idUUID
interest_idUUID
valueint-1 | 0 | +1
commentvarchar(280)?
created_at / updated_attimestamptz

Unique: (voter_id, target_user_id, interest_id)


reputation_appeals

ColumnTypeNotes
idUUID PK
appellant_idUUID FK → users
interest_idUUID FK → interests
reasonvarchar(500)
statusvarchar(20)pending | accepted | rejected
restore_deltaint?Optional admin-set restoration amount
resolved_by_idUUID?Admin who resolved
resolved_attimestamptz?
admin_notevarchar(500)?
created_at / updated_attimestamptz

Partial unique index (in migration SQL): one pending appeal per (appellant, interest).


Invite Tree Context

invite_tree_nodes

ColumnTypeNotes
user_idUUID PK
inviter_user_idUUID?
pathtextDot-separated UUIDs root→node
depthint0 for root
created_attimestamptz

Indexes: inviter_user_id, path


Messaging Context

conversations

ColumnType
idUUID PK
kindtext (dm | group)
titletext?
created_at / updated_attimestamptz

conversation_members

ColumnTypeNotes
conversation_idUUID FK CASCADE
user_idUUID FK CASCADE
roletextowner | member
joined_attimestamptz
last_read_attimestamptz?
muted_attimestamptz?Per-conversation mute

PK: (conversation_id, user_id)

messages

ColumnType
idUUID PK
conversation_idUUID FK CASCADE
author_idUUID FK CASCADE
bodytext
created_attimestamptz

message_reactions

PK (message_id, user_id, emoji). emoji validated (1–8 graphemes).


Notifications Context

notifications

ColumnTypeNotes
idUUID PK
user_idUUID FK → users
kindtexte.g. reputation.changed | circle.invited
titletext
bodytext?
linktext?Deep link
payloadjsonb?Extra structured data
read_attimestamptz?Null = unread
created_attimestamptz

Indexes: (user_id, read_at), created_at

notification_preferences

ColumnTypeNotes
user_idUUID PK FK → users
channelsjsonb{"push":true,"email":true,"inApp":true}
kindsjsonb{"reputation.changed": false}
updated_attimestamptz

AI Assistant Context

assistant_sessions

ColumnTypeNotes
idUUID PK
user_idUUID FK → users
statustextin_progress | completed | abandoned
topictext?Starting interest slug
summarytext?LLM-generated summary
started_at / completed_attimestamptz

assistant_turns

ColumnTypeNotes
idUUID PK
session_idUUID FK CASCADE
stepint1–5
prompttextAssistant question
answertext?User reply
created_at / answered_attimestamptz

Unique: (session_id, step)

assistant_suggestions

ColumnTypeNotes
idUUID PK
session_idUUID FK CASCADE
interest_idUUID FK → interests
confidencefloat0–1
depthint1–5
reasonvarchar(280)?LLM reasoning
appliedboolTrue once user accepted
created_attimestamptz

Economy Context

emoji_wallets

ColumnTypeNotes
user_idUUID PK FK → users
balanceintCurrent mana
lifetime_earnedint
lifetime_spentint
last_regen_attimestamptzGuard for nightly regen
updated_attimestamptz

emoji_payments

Append-only ledger. Never UPDATE.

ColumnTypeNotes
idUUID PK
sender_idUUID
receiver_idUUID
post_idUUID?Payment context
reply_idUUID?
emotionvarchar(20)One of 8 canonical emotions
tierint0–5
amountintMana transferred
reversedboolTrue = reversal row
created_attimestamptz

Indexes: (sender_id, created_at), (receiver_id, created_at), post_id, reply_id


Contribution Context

contribution_events

Append-only. Never UPDATE.

ColumnTypeNotes
idUUID PK
user_idUUID
kindvarchar(40)post_created | reply_created | reaction_given | emoji_pay_sent
deltaintPositive; reversals use negative delta
ref_idUUID?Reference ID for audit
created_attimestamptz

contribution_snapshots

ColumnType
user_idUUID PK
totalint
updated_attimestamptz

Wiki Context

wiki_entries

ColumnType
idUUID PK
interest_idUUID FK
contenttext
summaryvarchar(280)?
author_idUUID FK
versionint
created_at / updated_attimestamptz

Unique: (interest_id, version)

wiki_proposals

ColumnTypeNotes
idUUID PK
interest_idUUID FK
contenttext
summaryvarchar(280)?
proposer_idUUID FK
statusvarchar(20)pending | approved | rejected
net_votesintMaterialised counter
created_at / updated_attimestamptz

wiki_votes

ColumnType
idUUID PK
proposal_idUUID FK → wiki_proposals
voter_idUUID
valueint (+1 | -1)
created_attimestamptz

Unique: (proposal_id, voter_id)


System Tables

feature_flags

ColumnTypeNotes
keyvarchar(60) PKe.g. emoji-pay
enabledbool
labelvarchar(120)Admin UI label
descriptionvarchar(280)?
updated_attimestamptz
updated_byUUID?Admin who last toggled

audit_log

Append-only. Never UPDATE/DELETE.

ColumnTypeNotes
idUUID PK
actor_idUUIDAdmin/moderator
actiontexte.g. user.ban | post.hide
target_typetextuser | post | circle
target_idtext?Resource UUID (string — not a real FK)
payloadjsonbPrior/next state, reason
iptext?Best-effort from CF-Connecting-IP
user_agenttext?
created_attimestamptz

Indexes: (actor_id, created_at), (action, created_at), (target_type, target_id)

reports

ColumnTypeNotes
idUUID PK
reporter_idUUID FK → users
target_typetextpost | reply | user
target_idUUID
reasontextspam | harassment | illegal | misinfo | other
notetext?
statustextopen | reviewed | dismissed
reviewed_by_idUUID?
reviewed_attimestamptz?
created_attimestamptz

Regulus — invite-only social-knowledge platform