Parent
#328. Related: #293
What to build
A mention in the first message of a new heading chat notifies nobody who never opened that heading chat. @everyone in a first message fails the same way. The cause is trigger order: the mention fan-out runs before the step that adds workspace members to the chat. After this fix, a first-message mention reaches the named member, as every later message already does. In the same change, the chat mention picker stops listing people who left the workspace.
Acceptance criteria
Blocked by
None — can start now.
Agent brief
Type: AFK — an agent can finish this alone.
Category: bug
Current behavior:
- All message fan-outs are
AFTER INSERT triggers on public.messages. PostgreSQL fires triggers of one kind in name order.
create_everyone_notifications and create_mention_notifications sort before increment_unread_count. So they read channel_members before enrolment adds anyone.
- The mention fan-out joins
channel_members (packages/supabase/scripts/10-func-notifications.sql:72). Its trigger is at :86.
- The
@everyone fan-out loops over channel_members (:218-222). Its trigger is at :255.
- Enrolment is the PUBLIC-only
INSERT inside increment_unread_count_on_new_message (:487-512). Its trigger is at :534.
- Reproduced on local Supabase on 2026-09-28, in a rolled-back transaction: first-message
@username gave 0 notices, and first-message @everyone gave 0 notices.
fetch_mentioned_users builds both rosters from channel_members joined to channels (packages/supabase/scripts/10-functions.sql:745-748 and :765-768). Neither roster query checks workspace_members.left_at.
- Reproduced in the same transaction: after B left, a search for B's username still listed B.
Desired behavior:
- A new
BEFORE INSERT trigger on public.messages does the enrolment. A BEFORE trigger fires before every AFTER trigger, whatever the names. Do not rename triggers to fix the order.
increment_unread_count_on_new_message keeps only its unread UPDATE, unchanged. That UPDATE still takes a fresh row from 0 to 1.
- Both roster queries in
fetch_mentioned_users add a join to public.workspace_members on workspace_id = _workspace_id, member_id = u.id and left_at IS NULL.
Build notes. Read these before you write SQL.
- The seed offset changes meaning. In the AFTER trigger, NEW is already in
messages, so LIMIT 1 OFFSET 1 reads the previous message. In a BEFORE trigger, NEW is not there yet. Use LIMIT 1 with no offset, or the seed skips the real previous message.
- Move the enrolment
INSERT as it is. Keep the channel_type_var = 'PUBLIC' gate and the comment above it. Keep wm.left_at IS NULL and wm.member_id != NEW.user_id.
- Keep the
NOT EXISTS guard. check_duplicate_member (10-2-func-channels.sql:343-370) raises an exception on a duplicate. ON CONFLICT does not catch a trigger exception.
- Mark the new function like its siblings. Use
SECURITY DEFINER and SET search_path = public. Add it to both ALTER FUNCTION lists at the end of 10-func-notifications.sql. It writes other users' channel_members rows.
- Trigger shape. Use
FOR EACH ROW, WHEN (NEW.type IS DISTINCT FROM 'notification'), and end with RETURN NEW. No existing BEFORE INSERT trigger on messages returns NULL. One of them, validate_message_medias (10-3-func-message.sql), raises on bad media. A raise rolls back the whole insert, and the enrolment with it.
- Side effects that stay the same.
channel_members.notif_state defaults to MENTIONS (08-channel_members.sql:13). So the regular-message fan-out, which needs ALL, sends nothing new.
- Restate definer in the migration. In
scripts/, increment_unread_count_on_new_message gets SECURITY DEFINER and its search_path only from the ALTER FUNCTION lists. CREATE OR REPLACE FUNCTION resets what it does not state. So the migration states security definer and set search_path = public on both trigger functions, as the model migration does.
- The roster join works as the caller.
fetch_mentioned_users is not SECURITY DEFINER. The workspace_members_select policy admits rows for a workspace the caller belongs to (13-RLS.sql:109-111). Do not make it SECURITY DEFINER. In scripts/ it gets its search_path from an ALTER FUNCTION line (10-functions.sql:1006), so the migration states set search_path = public on it too.
Where to start:
packages/supabase/scripts/10-func-notifications.sql: increment_unread_count_on_new_message, the increment_unread_count trigger, and the hardening lists at the end.
packages/supabase/scripts/10-functions.sql: fetch_mentioned_users.
- Model the migration on
packages/supabase/migrations/20260921120000_chat_mention_token_rule.sql. Write it by hand. Make it idempotent with create or replace function and drop trigger if exists. Do not use the functions parity generator.
- Name the migration
<YYYYMMDDHHMMSS>_<snake_case_name>.sql, with a timestamp later than every file already in packages/supabase/migrations/.
Line numbers are hints as of 2026-09-28; the agent searches by symbol.
Rules that apply:
packages/supabase/CLAUDE.md §Supabase, these bullets:
- the PUBLIC-only enrolment bullet;
- "Migrations and
scripts/* are paired";
- "Migrations must ship dependencies they call";
- "Editing an uncommitted migration file requires re-apply";
- the types regeneration rule.
apps/webapp/src/components/chatroom/CLAUDE.md §Mention Picker: the "Mention notifications (SQL)" bullet. The token rule stays as it is.
.cursor/rules/supabase.mdc for SQL style.
- AGENTS.md §Test Policy: add no test file. The rolled-back check below is the proof.
Verify:
Local db reset does not run migrations/ (packages/supabase/config.toml [db.migrations] enabled = false). It loads only seed.sql. So test the migration by hand over the old schema, as production gets it. Run every command from the repo root.
- Save the check below as
check.sql outside the repo. Run it with docker exec -i supabase_db_docsplus_supabase psql -U postgres -d postgres -v ON_ERROR_STOP=1 < check.sql.
- Prove it by sabotage. Before any edit, run
bun run --filter @docs.plus/supabase_back reset, then the check. On 2026-09-28 it printed mention 0, @everyone 0, unread 1, and left_member_listed 1. The unread line is a guard that must not change, not a proof.
- Write the migration. Apply it over this old schema with
docker exec -i supabase_db_docsplus_supabase psql -U postgres -d postgres -v ON_ERROR_STOP=1 < packages/supabase/migrations/<file>.sql. Apply it twice: both runs must succeed.
- Run the check. It must print mention
1, @everyone 1, unread 1, and left_member_listed 0.
- Run
select proname, prosecdef, proconfig from pg_proc where proname in ('increment_unread_count_on_new_message', '<new function>');. Both rows must show prosecdef = t and search_path=public.
- Edit
scripts/ to match. Run bun run --filter @docs.plus/supabase_back reset. It regenerates seed.sql and resets the local database.
- Run the check and the
pg_proc query again. Both must give the same results as steps 4 and 5.
- Apply the migration once more over this new schema. It must succeed, and the check must still pass.
- Run
bun run --filter @docs.plus/supabase_back types.
- In the app, sign in as two members. Member A sends
@<B's username> as the first message in a heading chat that B never opened. B sees the notification. No UI changes, so one theme and one screen size are enough.
begin;
do $$
declare
a uuid := '00000000-0000-4000-8000-00000000e001';
b uuid := '00000000-0000-4000-8000-00000000e002';
b_name text;
ws varchar(36) := 'verify-enrol-ws';
m1 uuid; m2 uuid;
begin
-- on_auth_user_created makes each public.users row and its username.
insert into auth.users (id, email) values
(a, '[email protected]'), (b, '[email protected]');
select username into b_name from public.users where id = b;
perform set_config('verify.a', a::text, true);
perform set_config('verify.b', b::text, true);
perform set_config('verify.b_name', b_name, true);
insert into public.workspaces (id, name, slug, created_by) values (ws, ws, ws, a);
insert into public.workspace_members (workspace_id, member_id) values (ws, a), (ws, b);
insert into public.channels (id, workspace_id, slug, name, created_by, type) values
('verify-enrol-h1', ws, 'verify-enrol-h1', 'h1', a, 'PUBLIC'),
('verify-enrol-h2', ws, 'verify-enrol-h2', 'h2', a, 'PUBLIC');
insert into public.messages (channel_id, user_id, content)
values ('verify-enrol-h1', a, '@' || b_name || ' first message') returning id into m1;
insert into public.messages (channel_id, user_id, content)
values ('verify-enrol-h2', a, '@everyone first message') returning id into m2;
raise notice 'mention on a first message, notices for B: %',
(select count(*) from public.notifications where message_id = m1 and receiver_user_id = b);
raise notice '@everyone on a first message, notices for B: %',
(select count(*) from public.notifications where message_id = m2 and receiver_user_id = b);
raise notice 'unread for B in h1 after one message: %',
(select unread_message_count from public.channel_members
where channel_id = 'verify-enrol-h1' and member_id = b);
update public.workspace_members set left_at = now() where workspace_id = ws and member_id = b;
end $$;
set local role authenticated;
select set_config('request.jwt.claims',
json_build_object('sub', current_setting('verify.a'), 'role', 'authenticated')::text, true);
select count(*) as left_member_listed
from public.fetch_mentioned_users('verify-enrol-ws', current_setting('verify.b_name'))
where id = current_setting('verify.b')::uuid;
rollback;
The check runs as postgres, and the last query runs as member A through set local role authenticated. It makes its own two users, so it works right after a reset. It rolls everything back.
Out of scope
Parent
#328. Related: #293
What to build
A mention in the first message of a new heading chat notifies nobody who never opened that heading chat.
@everyonein a first message fails the same way. The cause is trigger order: the mention fan-out runs before the step that adds workspace members to the chat. After this fix, a first-message mention reaches the named member, as every later message already does. In the same change, the chat mention picker stops listing people who left the workspace.Acceptance criteria
@usernamecreates onementionnotification for that member.@everyonecreates onechannel_eventnotification for each other active member.unread_message_count = 1after it, not 0 and not 2.channel_membersrows.fetch_mentioned_usersno longer returns a user whoseworkspace_members.left_atis set, for an empty query and for a typed query.packages/supabase/scripts/and one new paired migration carry the same SQL.packages/supabase/seed.sqlis regenerated by its script, never edited by hand.apps/webapp/src/types/supabase.tschanges, the change includes it.packages/supabase/CLAUDE.mdbullet that namesincrement_unread_count_on_new_messageas the enroller names the new function. TheCOMMENT ONtexts match the new split.Blocked by
None — can start now.
Agent brief
Type: AFK — an agent can finish this alone.
Category: bug
Current behavior:
AFTER INSERTtriggers onpublic.messages. PostgreSQL fires triggers of one kind in name order.create_everyone_notificationsandcreate_mention_notificationssort beforeincrement_unread_count. So they readchannel_membersbefore enrolment adds anyone.channel_members(packages/supabase/scripts/10-func-notifications.sql:72). Its trigger is at:86.@everyonefan-out loops overchannel_members(:218-222). Its trigger is at:255.INSERTinsideincrement_unread_count_on_new_message(:487-512). Its trigger is at:534.@usernamegave 0 notices, and first-message@everyonegave 0 notices.fetch_mentioned_usersbuilds both rosters fromchannel_membersjoined tochannels(packages/supabase/scripts/10-functions.sql:745-748and:765-768). Neither roster query checksworkspace_members.left_at.Desired behavior:
BEFORE INSERTtrigger onpublic.messagesdoes the enrolment. A BEFORE trigger fires before every AFTER trigger, whatever the names. Do not rename triggers to fix the order.increment_unread_count_on_new_messagekeeps only its unreadUPDATE, unchanged. That UPDATE still takes a fresh row from 0 to 1.fetch_mentioned_usersadd a join topublic.workspace_membersonworkspace_id = _workspace_id,member_id = u.idandleft_at IS NULL.Build notes. Read these before you write SQL.
messages, soLIMIT 1 OFFSET 1reads the previous message. In a BEFORE trigger, NEW is not there yet. UseLIMIT 1with no offset, or the seed skips the real previous message.INSERTas it is. Keep thechannel_type_var = 'PUBLIC'gate and the comment above it. Keepwm.left_at IS NULLandwm.member_id != NEW.user_id.NOT EXISTSguard.check_duplicate_member(10-2-func-channels.sql:343-370) raises an exception on a duplicate.ON CONFLICTdoes not catch a trigger exception.SECURITY DEFINERandSET search_path = public. Add it to bothALTER FUNCTIONlists at the end of10-func-notifications.sql. It writes other users'channel_membersrows.FOR EACH ROW,WHEN (NEW.type IS DISTINCT FROM 'notification'), and end withRETURN NEW. No existing BEFORE INSERT trigger onmessagesreturns NULL. One of them,validate_message_medias(10-3-func-message.sql), raises on bad media. A raise rolls back the whole insert, and the enrolment with it.channel_members.notif_statedefaults toMENTIONS(08-channel_members.sql:13). So the regular-message fan-out, which needsALL, sends nothing new.scripts/,increment_unread_count_on_new_messagegetsSECURITY DEFINERand itssearch_pathonly from theALTER FUNCTIONlists.CREATE OR REPLACE FUNCTIONresets what it does not state. So the migration statessecurity definerandset search_path = publicon both trigger functions, as the model migration does.fetch_mentioned_usersis notSECURITY DEFINER. Theworkspace_members_selectpolicy admits rows for a workspace the caller belongs to (13-RLS.sql:109-111). Do not make itSECURITY DEFINER. Inscripts/it gets itssearch_pathfrom anALTER FUNCTIONline (10-functions.sql:1006), so the migration statesset search_path = publicon it too.Where to start:
packages/supabase/scripts/10-func-notifications.sql:increment_unread_count_on_new_message, theincrement_unread_counttrigger, and the hardening lists at the end.packages/supabase/scripts/10-functions.sql:fetch_mentioned_users.packages/supabase/migrations/20260921120000_chat_mention_token_rule.sql. Write it by hand. Make it idempotent withcreate or replace functionanddrop trigger if exists. Do not use the functions parity generator.<YYYYMMDDHHMMSS>_<snake_case_name>.sql, with a timestamp later than every file already inpackages/supabase/migrations/.Line numbers are hints as of 2026-09-28; the agent searches by symbol.
Rules that apply:
packages/supabase/CLAUDE.md§Supabase, these bullets:scripts/*are paired";apps/webapp/src/components/chatroom/CLAUDE.md§Mention Picker: the "Mention notifications (SQL)" bullet. The token rule stays as it is..cursor/rules/supabase.mdcfor SQL style.Verify:
Local
db resetdoes not runmigrations/(packages/supabase/config.toml[db.migrations] enabled = false). It loads onlyseed.sql. So test the migration by hand over the old schema, as production gets it. Run every command from the repo root.check.sqloutside the repo. Run it withdocker exec -i supabase_db_docsplus_supabase psql -U postgres -d postgres -v ON_ERROR_STOP=1 < check.sql.bun run --filter @docs.plus/supabase_back reset, then the check. On 2026-09-28 it printed mention0,@everyone0, unread1, andleft_member_listed1. The unread line is a guard that must not change, not a proof.docker exec -i supabase_db_docsplus_supabase psql -U postgres -d postgres -v ON_ERROR_STOP=1 < packages/supabase/migrations/<file>.sql. Apply it twice: both runs must succeed.1,@everyone1, unread1, andleft_member_listed0.select proname, prosecdef, proconfig from pg_proc where proname in ('increment_unread_count_on_new_message', '<new function>');. Both rows must showprosecdef = tandsearch_path=public.scripts/to match. Runbun run --filter @docs.plus/supabase_back reset. It regeneratesseed.sqland resets the local database.pg_procquery again. Both must give the same results as steps 4 and 5.bun run --filter @docs.plus/supabase_back types.@<B's username>as the first message in a heading chat that B never opened. B sees the notification. No UI changes, so one theme and one screen size are enough.The check runs as
postgres, and the last query runs as member A throughset local role authenticated. It makes its own two users, so it works right after areset. It rolls everything back.Out of scope
UPDATEor the notification fan-outs select, beyond the enrolment order.fetch_mentioned_userschanges, such as the empty-query order or its result limits.@list — Add a pad @ list where Members open a Comment and Headings insert a link #349. It waits on this issue.