IsotopeAI / docs

Guides

Getting started Configuration Supabase setup Sync & backup Backup console Community

Reference

Architecture API reference Database CLI Android APK Admin console

Help

Troubleshooting FAQ Contributing Changelog

Legal

Security Privacy Terms License

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.

42Tables
80Functions
153RLS policies
124Indexes (66 declared)

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.

TableColumnsPoliciesPurpose
users199Identity, plan, coins, gems, sync metadata. FK to auth.users(id).
user_profiles122JSONB profile blob, handle, display name.
user_display_profiles51Denormalised public display fields, kept in sync by trigger.
user_settings92Per-user preferences as JSONB.
user_onboarding62Completion flag and timestamp.
user_roles51Admin role grants, checked by isSupabaseAdminUser().
user_tours102Guided-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.

TableColumnsPurpose
focus_sessions41Individual focus sessions with full context.
mock_tests36Mock test attempts, per-section scoring and accuracy.
tasks29Tasks with priority, due dates and availability rules.
exams23Target exams, dates and countdown metadata.
subjects16Subjects with colour, icon and syllabus linkage.
daily_logs16Free-form daily journal entries.
tests15Lightweight self-tests.
habits14Habit definitions and streak state.
study_sessions_log14Append-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.

TableColumnsPoliciesPurpose
user_stats_summary147Aggregate totals, streaks and session counts.
daily_user_stats107Seconds studied per day, per user.
user_points46Current and lifetime points.
user_inventory62Owned store items.
store_items92Purchasable 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 #

TableColumnsPoliciesPurpose
groups2711Group record: name, exam, target year, subjects, visibility, join policy, member count.
group_members812Membership and role. The most heavily policed table in the schema.
group_chat_messages106Group chat, published to Realtime.
group_invites97Invite tokens with use counts and expiry.
group_challenges117Group challenge definitions.
group_challenge_participants77Per-user challenge progress.
group_announcements68Pinned announcements.
group_milestones43Group 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 #

TableColumnsPoliciesPurpose
community_enrollments82Opt-in state, privacy settings, quiet hours, day offset.
community_friends72Buddy connections: user_id, friend_id, status, accepted_at.
community_join_requests52Pending requests for groups with join_policy = 'request'.
community_start_alerts92Notify-me-when-they-start alerts, with quiet-hour windows.
community_device_tokens52Push tokens for native notifications.
community_reports62User reports on groups, messages or people.
community_events184Scheduled community events.
community_event_attendees36Event attendance, unique per event and user.
user_presence147Live presence: state, current subject and task, session start, running total.
buddy_invites70Unused. 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 #

TableColumnsPurpose
sync_items16Durable per-user sync queue and history.
backup_manifests13Records each stored backup: bucket, path, kind, content hash.
notifications13In-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.userspublic.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.

TriggerOnEffect
trg_ensure_user_profileusersCreates user_profiles.
trg_ensure_statsusersCreates user_stats_summary.
trg_ensure_user_pointsusersCreates user_points.
trg_ensure_onboardingusersCreates user_onboarding.
trg_ensure_community_enrollmentusersCreates community_enrollments.
sync_user_display_profileusersMirrors public display fields to user_display_profiles.
trg_auto_add_ownergroupsAdds the creator to group_members as owner.
trg_auto_add_super_admingroupsAdds the platform super admin to every new group.
trg_set_group_sluggroupsGenerates a URL-safe slug from the name.
trg_sync_group_visibilitygroupsKeeps is_public consistent with visibility.
trg_sync_member_countgroup_membersMaintains the denormalised groups.member_count.
tr_sync_user_onboarding_from_profileuser_profilesReconciles legacy onboarding flags.
tr_cleanup_old_notificationsnotificationsPrunes old rows on insert.
trg_user_tours_updated_atuser_toursMaintains updated_at.
on_auth_user_createdauth.usersSeeds 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.

AreaFunctions
Groupscommunity_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
Chatcommunity_get_group_messages, community_send_group_message
Invitescommunity_create_invite, community_preview_invite, community_redeem_invite, accept_invite, get_invite_details
Buddiescommunity_request_buddy, community_respond_buddy, community_remove_buddy
Presencecommunity_heartbeat, expire_stale_presence
Privacycommunity_get_privacy, community_save_privacy, community_sync_quiet_hours
Alertscommunity_get_start_alert, community_set_start_alert, community_register_device_token
Leaderboardsget_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:

PatternShapeUsed for
Own row onlyusing (user_id = (select auth.uid()))Study data, settings, private records.
Public read, own writefor select using (true) plus an own-row for allStats tables, so leaderboards work.
Membership gatedusing (_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:

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 #

FileUse
isotope-complete.sqlAuthoritative fresh install. Idempotent. Served by /__admin/schema.
community-patch-v6.sqlCumulative community patch for existing projects. Served by /__admin/patch.
leaderboard-rls-fix.sqlUpgrade only. Fixes an empty leaderboard.
performance-patch.sqlIndexes for RLS subqueries and leaderboard sorts.
sql/isotope-schema-restore.sqlFull portable dump for restoring into a new project.
events-expansion.sqlRemoval patch. Drops Store and Events. Do not run on a fresh install.