Database
Every table, function, policy and trigger in an IsotopeAI Supabase project. The figures below were read from a live schema, not inferred from the migration files.
Row-level security is on for all 42 tables There are no tables with RLS disabled. Every read and write from the browser is filtered by policy, using the caller's own JWT.
Check your own schema #
Run this in the Supabase SQL editor to compare your project against the reference figures above:
select
(select count(*) from information_schema.tables
where table_schema = 'public' and table_type = 'BASE TABLE') as tables,
(select count(*) from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.prokind = 'f') as functions,
(select count(*) from pg_policies where schemaname = 'public') as policies,
(select count(*) from pg_trigger t join pg_class c on c.oid = t.tgrelid
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and not t.tgisinternal) as triggers,
(select count(*) from pg_indexes where schemaname = 'public') as indexes;
The repository also ships a drift checker that fails if the install schema falls behind the live database:
npm run schema:drift
Users and profile #
public.users is the hub. It mirrors auth.users by primary
key and carries the application-level fields. Everything else hangs off it.
| Table | Columns | Policies | Purpose |
|---|---|---|---|
users | 19 | 9 | Identity, plan, coins, gems, sync metadata. FK to auth.users(id). |
user_profiles | 12 | 2 | JSONB profile blob, handle, display name. |
user_display_profiles | 5 | 1 | Denormalised public display fields, kept in sync by trigger. |
user_settings | 9 | 2 | Per-user preferences as JSONB. |
user_onboarding | 6 | 2 | Completion flag and timestamp. |
user_roles | 5 | 1 | Admin role grants, checked by isSupabaseAdminUser(). |
user_tours | 10 | 2 | Guided-tour progress, so tours do not repeat across devices. |
Study data #
The widest tables in the schema. focus_sessions has 41 columns because
a session records subject, task, interruptions, timing mode and derived totals in one
row.
| Table | Columns | Purpose |
|---|---|---|
focus_sessions | 41 | Individual focus sessions with full context. |
mock_tests | 36 | Mock test attempts, per-section scoring and accuracy. |
tasks | 29 | Tasks with priority, due dates and availability rules. |
exams | 23 | Target exams, dates and countdown metadata. |
subjects | 16 | Subjects with colour, icon and syllabus linkage. |
daily_logs | 16 | Free-form daily journal entries. |
tests | 15 | Lightweight self-tests. |
habits | 14 | Habit definitions and streak state. |
study_sessions_log | 14 | Append-only session log used for aggregation. |
Stats and gamification #
These are the tables the leaderboard reads. Both stats tables carry a public
SELECT policy plus own-row write policies — that combination is what makes
a leaderboard possible without exposing write access.
| Table | Columns | Policies | Purpose |
|---|---|---|---|
user_stats_summary | 14 | 7 | Aggregate totals, streaks and session counts. |
daily_user_stats | 10 | 7 | Seconds studied per day, per user. |
user_points | 4 | 6 | Current and lifetime points. |
user_inventory | 6 | 2 | Owned store items. |
store_items | 9 | 2 | Purchasable item catalogue. |
Empty leaderboard on an older project
A legacy stats_own policy used FOR ALL, which also blocked
public SELECT and made the leaderboard render empty. Run
leaderboard-rls-fix.sql if you see this. Fresh installs already have the
corrected stats_read_all policy.
Groups #
| Table | Columns | Policies | Purpose |
|---|---|---|---|
groups | 27 | 11 | Group record: name, exam, target year, subjects, visibility, join policy, member count. |
group_members | 8 | 12 | Membership and role. The most heavily policed table in the schema. |
group_chat_messages | 10 | 6 | Group chat, published to Realtime. |
group_invites | 9 | 7 | Invite tokens with use counts and expiry. |
group_challenges | 11 | 7 | Group challenge definitions. |
group_challenge_participants | 7 | 7 | Per-user challenge progress. |
group_announcements | 6 | 8 | Pinned announcements. |
group_milestones | 4 | 3 | Group achievement records. |
Avoiding infinite RLS recursion #
A membership policy that queries group_members directly re-triggers the
policy on group_members, and Postgres aborts with an infinite-recursion
error. Every membership check therefore goes through a SECURITY DEFINER
helper, which runs with the owner's rights and bypasses the policy it would otherwise
re-enter:
-- correct: definer function breaks the cycle
create or replace function public._is_group_member(gid uuid, uid uuid)
returns boolean language sql stable security definer
set search_path = public as $$
select exists (
select 1 from public.group_members
where group_id = gid and user_id = uid
);
$$;
-- wrong: this recurses and fails at query time
-- using (exists (select 1 from group_members where ...))
npm run security:verify asserts that the anon key cannot trigger
recursion on any policy.
Community #
| Table | Columns | Policies | Purpose |
|---|---|---|---|
community_enrollments | 8 | 2 | Opt-in state, privacy settings, quiet hours, day offset. |
community_friends | 7 | 2 | Buddy connections: user_id, friend_id, status, accepted_at. |
community_join_requests | 5 | 2 | Pending requests for groups with join_policy = 'request'. |
community_start_alerts | 9 | 2 | Notify-me-when-they-start alerts, with quiet-hour windows. |
community_device_tokens | 5 | 2 | Push tokens for native notifications. |
community_reports | 6 | 2 | User reports on groups, messages or people. |
community_events | 18 | 4 | Scheduled community events. |
community_event_attendees | 3 | 6 | Event attendance, unique per event and user. |
user_presence | 14 | 7 | Live presence: state, current subject and task, session start, running total. |
buddy_invites | 7 | 0 | Unused. See the note below. |
buddy_invites is an orphan
It has RLS enabled with zero policies, so no anonymous or
authenticated caller can read or write it. Nothing references it — not
isotope-complete.sql, not server.mjs, and none of the 73
functions. Buddy functionality runs entirely through
community_friends. This is leftover from an earlier design. It is not a
security hole, because RLS with no policies fails closed, but it is dead weight and
is scheduled for removal.
Sync and system #
| Table | Columns | Purpose |
|---|---|---|
sync_items | 16 | Durable per-user sync queue and history. |
backup_manifests | 13 | Records each stored backup: bucket, path, kind, content hash. |
notifications | 13 | In-app notification inbox, polled by the runtime. |
The sync quartet #
Six tables carry the same four columns, and the sync engine depends on all of them:
- version
- Monotonic counter. Higher wins when content hashes differ.
- content_hash
- Stable hash of the row payload. Equal hashes mean no write is needed.
- last_synced_at
- When this row last reconciled with the cloud.
- deleted_at
- Tombstone. A soft delete must replicate; a hard delete cannot.
- device_id
- Which device produced the write, used to break ties.
They exist on users, user_profiles,
user_settings, notifications,
study_sessions_log and daily_user_stats.
Triggers #
15 triggers. Fourteen live in public, and six of those fire on
public.users so a new account is never missing a satellite row — the
alternative is an empty leaderboard or a non-functional community tab, caused by a row
that was never created.
The fifteenth is the one that matters most, and it is easy to lose: it lives on
auth.users, not in public.
The 14 above are the second stage, not the first
Every trigger in the table fires on INSERT INTO public.users. Nothing in
public copies auth.users → public.users — that is
on_auth_user_created on auth.users, calling
handle_new_user(). For a period it did not exist: the function was present,
nothing called it, and 32 of 43 accounts had an auth identity and no application
data at all. Signup appeared to succeed and then nothing worked.
It was also absent from the schema dump, because both dump tools exclude the
auth schema — correct for tables, wrong for a trigger attached to one. So a
restored project passed every check and could not accept a signup. Detect it with:
select count(*) from auth.users u where not exists (select 1 from public.users p where p.id = u.id); -- non-zero => the trigger is missing; supabase/022 restores it and backfills
Or run ./supabase.sh check, which asserts it unconditionally.
| Trigger | On | Effect |
|---|---|---|
trg_ensure_user_profile | users | Creates user_profiles. |
trg_ensure_stats | users | Creates user_stats_summary. |
trg_ensure_user_points | users | Creates user_points. |
trg_ensure_onboarding | users | Creates user_onboarding. |
trg_ensure_community_enrollment | users | Creates community_enrollments. |
sync_user_display_profile | users | Mirrors public display fields to user_display_profiles. |
trg_auto_add_owner | groups | Adds the creator to group_members as owner. |
trg_auto_add_super_admin | groups | Adds the platform super admin to every new group. |
trg_set_group_slug | groups | Generates a URL-safe slug from the name. |
trg_sync_group_visibility | groups | Keeps is_public consistent with visibility. |
trg_sync_member_count | group_members | Maintains the denormalised groups.member_count. |
tr_sync_user_onboarding_from_profile | user_profiles | Reconciles legacy onboarding flags. |
tr_cleanup_old_notifications | notifications | Prunes old rows on insert. |
trg_user_tours_updated_at | user_tours | Maintains updated_at. |
on_auth_user_created | auth.users | Seeds public.users and its five satellite rows on signup. Without it every new account is broken. |
Why trg_auto_add_owner matters
Creating a group and adding yourself as its owner are two writes. If the second
fails, you get a group nobody can administer. The trigger makes it one atomic
operation.
Functions #
80 CREATE FUNCTION statements covering 72 distinct names — eight are
overloads sharing a name with a different signature. 30 of them are the
community_* RPC surface the frontend
calls directly through PostgREST.
| Area | Functions |
|---|---|
| Groups | community_create_group, community_update_group, community_delete_group, community_get_group, community_discover_groups, community_join_group, community_leave_group, community_transfer_group, community_set_group_role, community_remove_group_member |
| Chat | community_get_group_messages, community_send_group_message |
| Invites | community_create_invite, community_preview_invite, community_redeem_invite, accept_invite, get_invite_details |
| Buddies | community_request_buddy, community_respond_buddy, community_remove_buddy |
| Presence | community_heartbeat, expire_stale_presence |
| Privacy | community_get_privacy, community_save_privacy, community_sync_quiet_hours |
| Alerts | community_get_start_alert, community_set_start_alert, community_register_device_token |
| Leaderboards | get_group_leaderboard, get_leaderboard, get_group_analytics_from_snapshots |
| Helpers | _is_group_member, _has_group_role, is_premium_user, check_user_role, get_my_role |
Pinned search paths #
Every SECURITY DEFINER function pins its search_path.
Without that, a caller who can create objects in an earlier schema could shadow a table
name and have the function operate on their object with the definer's privileges:
-- find any definer function missing a pinned search_path select p.proname, pg_get_function_identity_arguments(p.oid) as args from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.prosecdef and p.proconfig is null; -- expected: zero rows
To fix one without touching its body:
alter function public.my_function(uuid, integer) set search_path = public;
RLS model #
Three patterns cover almost every table:
| Pattern | Shape | Used for |
|---|---|---|
| Own row only | using (user_id = (select auth.uid())) | Study data, settings, private records. |
| Public read, own write | for select using (true) plus an own-row for all | Stats tables, so leaderboards work. |
| Membership gated | using (_is_group_member(group_id, (select auth.uid()))) | Group content: chat, announcements, challenges. |
(select auth.uid()) rather than a bare auth.uid() is
deliberate: the subquery form is evaluated once per statement instead of once per row,
which matters on large tables.
Indexes #
66 declared indexes, 124 in total. Both numbers are correct and
the difference is not a discrepancy: a PRIMARY KEY or
UNIQUE constraint creates an index of its own, so
pg_indexes reports 124 while isotope-complete.sql
contains 66 explicit CREATE INDEX statements. If you are checking a
project against the schema file, 66 is the figure to compare; if you are reading
pg_indexes, expect 124.
The ones that matter most for perceived speed:
group_members(group_id, user_id)— covers the membership subquery used by every gated policy.groups(slug)— group lookup by URL.daily_user_stats(user_id, date)— leaderboard range scans.- A GIN index on
groups.fts— full-text discovery search.
groups.fts is a generated column, so the search vector cannot drift from
the source text:
fts tsvector generated always as (
to_tsvector('english',
coalesce(name, '') || ' ' || coalesce(description, '') || ' ' || coalesce(category, ''))
) stored
Schema files #
| File | Use |
|---|---|
isotope-complete.sql | Authoritative fresh install. Idempotent. Served by /__admin/schema. |
community-patch-v6.sql | Cumulative community patch for existing projects. Served by /__admin/patch. |
leaderboard-rls-fix.sql | Upgrade only. Fixes an empty leaderboard. |
performance-patch.sql | Indexes for RLS subqueries and leaderboard sorts. |
sql/isotope-schema-restore.sql | Full portable dump for restoring into a new project. |
events-expansion.sql | Removal patch. Drops Store and Events. Do not run on a fresh install. |