Peopleuserslinked_accountsauth_sessionsdevice_tokenswidget_credentialsprofile_pictures6Scheduleeventsproposed_timesinvitationsinvite_linkspending_email_invitesnotifications6AIresolutionsai_commentsweather_reportsai_participantsai_usage_logsai_usage_limitsai_model_pricesai_grounding_pricesimage_sessionsimage_takesimage_looks11Event chatchat_messageschat_read_statechat_message_reactions3DMsdm_threadsdm_messagesdm_read_statedm_message_reactions4Channelschannelschannel_memberschannel_messageschannel_read_statechannel_message_reactionschannel_join_requestschannel_blocks7Mediaphotosphoto_variantsphoto_usagesmedia_ai_texts4Decisionsdecisionsdecision_optionsdecision_participantsrecommendationsoption_insightsdecision_context_itemsdecision_ai_commentsdecision_ai_participantsdecision_messagesdecision_message_reactionsdecision_chat_read_statedecision_invite_linksdecision_pending_email_invites13Tripstripstrip_memberscontainer_linkstrip_notestrip_documentstrip_mediatrip_briefstrip_ai_commentstrip_messagestrip_message_reactionstrip_chat_read_statetrip_invite_linkstrip_pending_email_invites13Levelsuser_levelsuser_level_grantsuser_creationsimage_take_ledger4Notesnotesnote_membersnote_docsnote_lognote_actorsnote_versionsnote_clean_up_runsnote_imagesnote_invite_linksnote_pending_email_invites10Linkslink_snapshots1Opsdatabase_backups1
Schema · PostgreSQL 18 · Flyway

The Shape of the Data

Eighty-three tables in thirteen domains, every person’s data wired to one users table — and every rule that can live in Postgres, living there.

83
tables
13
domains
99
migrations
0
migrations edited

One spine, thirteen domains

Cue’s data lives in PostgreSQL 18, and the schema changes only through Flyway: every change is a numbered SQL migration, reviewed like code and never edited once shipped. Hibernate runs in validate mode — it checks that the Kotlin entities match the tables at startup, and never writes DDL of its own.

The 83 tables group into thirteen domains. Every domain that holds people’s data has at least one foreign key into users. The last two belong to no one: Link previews, a cache of public pages every chat shares, and Operations, the backup ledger. Each section below draws one domain as an entity-relationship diagram: the columns that carry its rules, the relations between its tables, and the constraints Postgres enforces on them. A crow’s foot marks the many side of a relation, a bar the one side.

Fig. 1 / The spine
usersthe spineIdentity6 tablesScheduling6 tablesIntelligence11 tablesEvent chat3 tablesDirect messages4 tablesChannels7 tablesMedia4 tablesDecisions13 tablesTrips13 tablesUser levels4 tablesCue Notes10 tables
Eleven domains reach users directly. Tap one to jump to its tables.
01Identity6 tables

Everything hangs off a person

Every row in Cue traces back to a person. A user appears the first time someone signs in — with Google, Apple, or a six-digit email code — and is never asked to invent a username. Sign-in identities live beside the row rather than inside it, so one person can link several providers and stay a single account.

Fig. 2 / Identity
usersidPKemailnameUQhandleUQlinked_accountsuser_idFKproviderprovider_subjectdevice_tokensuser_idFKtokenUQsession_family_idUUIDauth_sessionsidPKuser_idFKfamily_idUUIDreplaced_byFKprofile_picturesuser_idPK FKimage_dataBYTEAcontent_typeUNIQUE (provider, provider_subject)UNIQUE LOWER(handle)
profile_pictures is keyed by user_id itself, so a person has at most one; each auth_sessions row points at the row that replaced it.
Also in this domainwidget_credentials: the iOS Chats widget's own credential, one per sign-in
  • Two names, on purpose. provider_name follows the Google or Apple display name and refreshes on every sign-in; name is an optional name of your own, unique case-insensitively through a LOWER(name) index. The public handle gets the same treatment.
  • Heavy bytes live apart. Uploaded avatars sit in profile_pictures as BYTEA, so the hot users row stays small. Apart from Cue Notes’ documents, every other byte lives in R2.
  • One row per rotation. auth_sessions stores only a SHA-256 digest of each refresh token. Every rotation descended from one sign-in shares a family_id, so a token replayed after its successor was spent revokes the whole chain in a single UPDATE.
  • A sign-in takes its devices with it. device_tokens and widget_credentials carry the family that registered them, so a logout, a replayed token or a lapsed session ends that phone’s pushes and its widget’s credential along with the session.
02Scheduling6 tables

A time that works for everyone

An organizer proposes a few times and invitees answer in plain language. That whole exchange fits in a handful of tables: the event, its candidate slots, one invitation per guest holding their free-text reply, and the notifications that nudge everyone along.

Fig. 3 / Scheduling
eventsidPKcreated_byFKstatusCHECKproposed_timesevent_idFKdate_timeinvitationsevent_idFKuser_idFKresponse_textroleCHECKnotificationsevent_idFKtypeinvite_linksevent_idFK UQtokenUQpending_email_invitesevent_idFKemailUNIQUE (event_id, user_id)CHECK num_nonnulls(event_id, …) = 1
invite_links is unique on event_id, so an event has at most one join link, and asking for it again returns the same token.
  • One reply per guest. A unique (event_id, user_id) on invitations makes “exactly one response each” the database’s job, not the application’s.
  • Invite a stranger. pending_email_invites parks an invitation against an email address and attaches it the moment that person signs up.
  • More than one organizer. A role column on the same invitation row lets the creator share the organizing powers with anyone already invited — no extra table, because every guest already has a row.
  • A nudge points at one thing. notifications carries three nullable foreign keys and a CHECK that exactly one is set: an event, a decision or a trip.
03Intelligence11 tables

Gemini’s work, written down

Every AI feature re-runs as the conversation changes and saves its output as a fresh row: the suggested time, a persona’s comment, a practical read of the weather. All of it hangs off the event, with token usage and the budget tracked alongside. A cover Cue paints is the exception: one person asks for it, so its session hangs off them.

Fig. 4 / Intelligence
eventsidPKresolutionsevent_idFKscoresJSONBai_commentsevent_idFKcommentator_typeweather_reportsevent_idFKreportai_participantsevent_idFKnameremoved_atai_usage_logsevent_idFKfeatureinput_tokensai_usage_limitsidPKmonthly_cost_limit_usdai_model_pricesmodeleffective_frominput_price_per_mai_grounding_priceseffective_fromprice_per_queryUNIQUE (event_id, name) WHERE removed_at IS NULL
events is dashed because it belongs to Scheduling. ai_usage_limits and the two price catalogs have no foreign key at all: one row of budget caps, and prices dated by the day they take effect, where an image model’s row adds its own rate for image output.
Also in this domainimage_sessions: one person painting one image: what Cue read, its ideaimage_takes: each painted take, its bytes in R2 until sweptimage_looks: a person's style, mood and painter for every image
  • Latest wins, history stays. Each re-run writes a new row. Only the newest is shown; older ones stay for transparency and debugging. Per-slot scores ride in JSONB, so their shape can change without a migration. A status marks each row ok, rate_limited or failed, and a fingerprint of the inputs lets an unchanged request skip the call.
  • Nobody torches the budget. Every Gemini call lands in ai_usage_logs with its model, token counts and cost — priced once, when it is logged, at its model’s row in ai_model_prices — and the caps in the single ai_usage_limits row are checked before the next one runs. A usage row is the bill, so it outlives the event, decision or trip it was charged to.
  • A cover is a session. image_sessions holds one person painting one image, a cover or a preview of their look: what Cue read, its idea and the look it froze at the start. image_takes holds each picture, its bytes in R2 under a storage_key. The image prompt stays server-side. A kept take becomes an ordinary photos row; the rest are swept with their session. image_looks keeps one row per person, and no row means Cue decides.
  • One name per room. A partial unique index allows one active AI participant per name on each event. A removed participant keeps its row, so its old messages still say who wrote them.
04Event chat3 tables

A live room on every event

Switch chat on and an event grows a message stream. People and AI participants post into the same table; read cursors and emoji reactions live in companions of their own.

Fig. 5 / Event chat
chat_messagesidPKevent_idFKreply_to_message_idFKsender_user_idFKsender_ai_idFKtextchat_read_stateuser_idPK FKevent_idPK FKlast_read_message_idFKmuted_atchat_message_reactionsmessage_idFKuser_idFKemojiai_participantsidPKnameCHECK exactly one of sender_user_id, sender_ai_idUNIQUE (message_id, user_id, emoji)
ai_participants belongs to the Intelligence domain — it is the bridge between the two. A message can reply to another message in the same table.
  • Human or AI, never both. A CHECK guarantees each message has exactly one author: a user or an AI participant.
  • Built to page back. An index on (event_id, id DESC) lets “load older messages” walk the history in index order, newest first.
  • A delete never takes your read state with it. last_read_message_id is ON DELETE SET NULL: deleting the message a cursor points at clears the cursor instead of failing, and the row keeps its mute and marked-unread flags.
05Direct messages4 tables

Two people, one canonical row

A direct-message thread mirrors event chat almost exactly — messages, read cursors, reactions — but it belongs to a pair of people instead of an event. The neat part is the ordering that keeps every pair to one row.

Fig. 6 / Direct messages
dm_threadsidPKuser_a_idFKuser_b_idFKinitiator_user_idFKstatusCHECKdm_messagesthread_idFKidPKtextdm_message_reactionsmessage_idFKuser_idFKemojidm_read_stateuser_idPK FKthread_idPK FKlast_read_message_idFKarchived_atCHECK user_a_id < user_b_idUNIQUE (user_a_id, user_b_id)CHECK initiator_user_id IN (user_a_id, user_b_id)
The CHECK requires the smaller user id first, so a lookup never tries a pair both ways, and the unique index makes each pair a single thread.
  • user_a_id < user_b_id, always. Whoever writes first, the pair is stored in the same order, so every two people map to at most one row.
  • A quiet decline. A thread carries a status. Declining silently stops the fan-out — no notification, no leak — and the thread flips back to pending only if the person who declined writes back.
  • Archived, not deleted. archived_at hides a thread from the inbox until a new message brings it back. Only direct messages have it; other kinds mute or leave.
06Channels7 tables

A room that belongs to no event

Standalone group chats like #oslo and #fotball, with their own membership, roles and three visibility tiers: public, private and secret. Seven tables that borrow every lesson the chat and DM tables learned first.

Fig. 7 / Channels
channelsidPKvisibilityCHECKowner_user_idFKchannel_memberschannel_idFKuser_idFKroleCHECKchannel_blockschannel_idFKuser_idFKblocked_by_user_idFKchannel_join_requestschannel_idFKuser_idFKstatusCHECKchannel_messageschannel_idFKsender_user_idFKtextUNIQUE (channel_id) WHERE role = 'owner'UNIQUE (…) WHERE status = 'pending'
Two partial unique indexes carry the room’s invariants: never two owners, and at most one pending request per person.
Also in this domainchannel_read_state: cursor, mute, marked unreadchannel_message_reactions: emoji, one of each per person
  • Never two owners. A partial unique index (WHERE role = 'owner') makes a second owner impossible — a database invariant, not a code convention.
  • Removed means blocked. A separate channel_blocks table keeps removed members off the hot fan-out path, instead of a WHERE removed_at IS NULL smeared across every query.
  • Requests keep their history. Decided join requests stay behind for audit and for the 24-hour cooldown after a denial; only a pending one is unique.
07Media4 tables

One library for photos, video and files

Everyone has one library holding photos, short videos and documents as a single kind of row. Each rendition — every size, every format — is a variant stored in Cloudflare R2, and a usage row records every place an item is shown.

Fig. 8 / Media
photosidPKowner_idFKcontent_hashmedia_kindphoto_variantsphoto_idFKlabelformatstorage_keyUQmedia_ai_textsphoto_idFKkindCHECKtextphoto_usagesphoto_idFKusage_typetarget_idCloudflare R2the bytes, by storage_keyany rowa message, a cover, a tripUNIQUE (owner_id, content_hash)UNIQUE (photo_id, label, format)UNIQUE (photo_id, kind)
The bytes never touch Postgres: a variant’s storage_key names its object in R2. target_id has no foreign key, because the row it points at lives in a different table for each usage_type.
  • One table, three media. A media_kind column — image, video or document — is all that separates them at the row level.
  • Upload a photo twice, store it once. A unique (owner_id, content_hash) deduplicates each person’s photos and documents; uploading a trashed photo again brings the original back — unless an admin took it down, when removed_at keeps the row past the purge as a tombstone that refuses the same bytes.
  • Read once, kept with the item. media_ai_texts holds at most one text per kind — a photo’s AI description, or the digest of a shared file — so later prompts carry the text instead of the bytes. It goes with its item, and a photo’s description goes when its uploader opts out.
  • Thirty days in the trash. deleted_at soft-deletes, and a partial index over trashed rows lets the purge job find expired ones without scanning anyone’s library.
08Decisions13 tables

A group picks what

Scheduling settles when; Decisions settles what — where to travel, what to name the band. A decision holds candidate options and one free-text preference per person, and Gemini weighs them into a recommended pick with per-option fit scores and a written rationale. It reuses the whole event playbook — invites, an AI commentator, a full chat — so thirteen tables come along.

Fig. 9 / Decisions
decisionsidPKfinalized_option_idFKstatusCHECKdecision_optionsdecision_idFKidPKlabeloption_insightsoption_idFKsourcesJSONBrecommendationsdecision_idFKrecommended_option_idFKfit_scoresJSONBdecision_participantsdecision_idFKuser_idFKpreference_textUNIQUE (decision_id, user_id)finalized_option_id … ON DELETE SET NULL
The two tables reference each other: an option belongs to its decision, and a finalized decision points back at the winner. Removing that option clears the pointer, never the decision.
Also in this domaindecision_context_items: background notes and filesdecision_ai_comments: a persona's takedecision_ai_participants: AI personalities in the chatdecision_messages: the decision's chatdecision_message_reactions: emoji, one of each per persondecision_chat_read_state: each reader's cursordecision_invite_links: one shareable join linkdecision_pending_email_invites: invitations to people not on Cue yet
  • The Events pillar, mirrored. recommendations is to a decision what resolutions is to an event: the model’s pick, re-run whenever options or preferences change, with fit scores in JSONB.
  • Grounded, with receipts. option_insights stores a cited brief per option, with its sources and search queries — only when the organizer switches insights on, because grounded calls cost money.
  • Everything else, reused. The AI commentator and participants, the chat trio and the invite pair mirror their event counterparts one for one. decision_context_items, the background a recommendation reads, is the one table events don’t have.
09Trips13 tables

A container, not a pillar

Events settle when and decisions settle what; a trip settles nothing — it organizes them. A trip is a container with its own roster, chat, notes, documents and album, gathering events and decisions into one timeline. Dates are optional and may be in the past, so the same row is equally a plan for next month and a memory from years ago.

Fig. 10 / Trips
tripsidPKstart_dateend_datecreated_byFKtrip_memberstrip_idFKuser_idFKroleCHECKtrip_notestrip_idFKbodynote_datetrip_mediatrip_idFKphoto_idFKcontainer_linkstrip_idFKchannel_idFKevent_idFKdecision_idFKtrip_documentstrip_idFKphoto_idFKtrip_briefstrip_idFKbriefCHECK num_nonnulls(event_id, decision_id) = 1CHECK num_nonnulls(channel_id, trip_id) = 1UNIQUE NULLS NOT DISTINCT
container_links holds one pillar and one container per row: nullable foreign keys to all four, and two CHECK constraints keeping exactly one of each.
Also in this domaintrip_ai_comments: a persona's taketrip_messages: the trip's chattrip_message_reactions: emoji, one of each per persontrip_chat_read_state: each reader's cursortrip_invite_links: one shareable join linktrip_pending_email_invites: invitations to people not on Cue yet
  • One table, both container kinds. Trips and channels share container_links, and UNIQUE NULLS NOT DISTINCT stops a pair from repeating. A new container kind is one more nullable column and a wider CHECK, not a new table.
  • A link is a tag, never a grant. ON DELETE CASCADE on all four sides means deleting either end drops only the link. Membership never grants access to a linked event or decision; authorization stays per resource.
  • Built to outlive its creator. created_by is ON DELETE SET NULL: a shared memory survives its creator’s account, where events and decisions leave with theirs.
10User levels4 tables

How much room a person gets

Three things cost real money as Cue grows: stored bytes, the fan-out every new event, decision or trip drags behind it, and the images Cue paints on demand. So each person has an effective level with an allowance on each. Room grows with tenure on its own, and an admin can pin anyone to any rung — up as a gift, down as a brake on abuse.

Fig. 11 / User levels
user_levelsidPKslugUQearned_after_daysUQstorage_bytesusersgranted_level_idFKidPKcreated_atuser_creationsuser_idFKkindcreated_atuser_level_grantsuser_idFKlevel_idFKgranted_byFKreasonCHECK earned_after_days IS NOT NULL OR grantable
users belongs to Identity. granted_level_id is an admin’s pin; left empty, the level is earned from the account’s age.
Also in this domainimage_take_ledger: the 30-day ledger of painted takes
  • Rows, not an enum. Levels are catalog rows because the set is open: adding a tier is an INSERT and retuning an allowance an UPDATE — never a change to the wire contract shipped apps depend on.
  • Earned is computed, never stored. Each earnable rung carries a threshold in days since users.created_at, so the earned level is simply the highest rung an account’s age clears. No promotion job, nothing to drift.
  • A ledger, because live rows are gameable. user_creations gains a row per create and never loses one to a delete — otherwise create, fan out invites and AI, delete, repeat would be free. image_take_ledger does the same for every take Cue paints. Only the rolling window returns a slot.
11Cue Notes10 tables

Writing together, never losing a word

A note is markdown several people write at once. Its truth isn’t the markdown column but an Automerge document — a CRDT — so a week offline, a save from a script and an AI rewrite all merge instead of colliding. Postgres keeps that document the way a log-structured store would: every change appended before anyone is told it is safe, folded into a snapshot now and then.

Fig. 12 / Cue Notes
notesidPKmarkdownheadsnote_membersnote_idFKuser_idFKroleCHECKnote_docsnote_idPK FKdocBYTEAversionnote_lognote_idFKdataBYTEAchange_countnote_versionsnote_idFKheadsreasonCHECKnote_imagesnote_idFKphoto_idFKattached_byFKnote_invite_linksnote_idFKroleCHECKtokenUQUNIQUE (note_id) WHERE role = 'owner'UNIQUE (note_id, user_id)UNIQUE (note_id, role)
note_docs is keyed by note_id itself, so a note has one snapshot, and note_log holds only the changes since it. notes.markdown is a cache the server rewrites from the document — never an input.
Also in this domainnote_actors: each CRDT actor, bound to one personnote_clean_up_runs: every Clean up asked for, and any refusalnote_pending_email_invites: invitations to people not on Cue yet
  • Stored before it’s acknowledged. A change reaches note_log and commits before the writer hears it is safe. Compaction folds the log into note_docs straight from those rows, and its version column lets exactly one of two racing replicas win.
  • Every author, bound. note_actors ties each CRDT actor to the one person who first wrote with it, and keeps the binding — its user_id set null — after they leave, so nobody can later write as a departed author.
  • History as copies. A note_versions row copies the markdown at a set of heads, with who wrote since the last one and why it was cut — an editing burst, a restore or a Clean up — so scrubbing back reads no document at all.
  • Images stay the uploader’s. note_images points at an ordinary photo in its uploader’s library, deliberately not a photo_usages row, so no Cue app’s where-used chips ever name a note.
13Operations1 table

The one table the app never writes

Every other table is written by cue-core. This one is written by the nightly backup job, from another machine, once each run ends — a failed run too, whenever production can still be reached.

Fig. 14 / Operations
database_backupsidPKstarted_atoutcomeCHECKfailed_stepCHECKrestored_rowsrestored_schemaCHECK outcome IN ('succeeded', 'failed')CHECK failed_step IN ('dump', …)CHECK (outcome = 'failed') = (failed_step …)
No foreign key leaves database_backups: the ledger belongs to no one. The admin dashboard reads it with plain SQL, so it is the one table without a Kotlin entity.
  • A failure names its step. A CHECK ties a failed_step — the dump, reading it back, or the restore check — to every failed run, and to nothing else.
  • Restored, not just saved. Each run restores its dump into a scratch database and records what came back: the tables, their rows, and the newest migration the dump carried.
  • Silence is a signal. The ledger lives in the database it describes, so a run that can’t reach production can’t report either. The dashboard reads the gap as a late backup. Rows are kept for good, one a night.
14Patterns

Two shapes, over a quarter of the schema

Thirteen domains, but over a quarter of all tables come in one of two shapes. Every conversation — event chat, direct messages, channels, decision chat, trip chat — is the same trio of tables under its own prefix, and a new chat kind copies it verbatim.

Fig. 15 / One chat, five kinds
eventDMchanneldecisiontripchat_messagesdm_messageschannel_messagesdecision_messagestrip_messagesidPKevent_idFKthread_idFKchannel_idFKdecision_idFKtrip_idFKsender_user_idFKreply_to_message_idFKtextchat_message_reactionsdm_message_reactionschannel_message_reactionsdecision_message_reactionstrip_message_reactionsmessage_idFKuser_idFKemojichat_read_statedm_read_statechannel_read_statedecision_chat_read_statetrip_chat_read_stateuser_idPK FKevent_idPK FKthread_idPK FKchannel_idPK FKdecision_idPK FKtrip_idPK FKlast_read_message_idFKmuted_atUNIQUE (message_id, user_id, emoji)last_read_message_id … ON DELETE SET NULL
The same three tables, cycling through all five kinds. Only the names and the container column change.

The second shape is the invite pair. Events, decisions, trips and notes — everything a person can be invited to — each carry a reusable join link and a table of invitations to people who aren’t on Cue yet:

KindTables
Eventsinvite_linkspending_email_invites
Decisionsdecision_invite_linksdecision_pending_email_invites
Tripstrip_invite_linkstrip_pending_email_invites
Notesnote_invite_linksnote_pending_email_invites

Each has at most one link — a note one per role, editor or viewer — carrying a 10-character token. An email invitation is unique per address and is redeemed when that person signs up.

15House rules

Rules every table follows

A handful of conventions run through the whole schema. Learn them once and every table reads the same way.

Flyway, never Hibernate DDL

Every change ships as a numbered V<n>__*.sql migration, and a shipped one is never edited — Flyway checksums them. Hibernate only validates.

One enum pattern

Enum-shaped columns are VARCHAR holding the lowercase wire value, written by a custom AttributeConverter. Newer tables pin closed sets with a CHECK; open vocabularies deliberately get none.

JSONB where the shape flexes

Per-slot scores, fit scores, cited sources and cover crops live in JSONB, so their structure can evolve without a migration.

Natural keys for per-user state

Every read cursor is keyed on (user_id, conversation) — a composite primary key with no surrogate id to look up.

The V28 lesson

Read cursors point at the last message read with ON DELETE SET NULL, so deleting a message clears a cursor instead of failing or taking the read state with it. Every chat kind since was born that way.

Partial indexes earn their keep

No second owner, no second pending request, no two active AIs with one name: invariants over kept history are partial uniques. The trash, stuck transcodes and live session families get partial indexes too.

Soft delete only where it matters

Photos and notes get a 30-day trash, and removed AI participants keep their rows so old messages still attribute. Everything else hard-deletes.

Search indexes the row itself

Thirteen tables carry a generated tsvector column, and twelve of them a trigram index beside it. A generated column can’t drift from the row it summarizes.

Keep exploring

Built by one person and a very capable AI.