Important Fixes, New mechanics, and many more
Some checks failed
CI / validate (push) Failing after 9m21s
Some checks failed
CI / validate (push) Failing after 9m21s
This commit is contained in:
2064
supabase/bootstrap.sql
Normal file
2064
supabase/bootstrap.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -38,7 +38,10 @@ create table public.world_entities (
|
||||
summary text not null,
|
||||
tags text[] not null default '{}',
|
||||
secrets jsonb not null default '[]' check (jsonb_typeof(secrets) = 'array'),
|
||||
search_document tsvector generated always as (to_tsvector('english', coalesce(name, '') || ' ' || coalesce(summary, '') || ' ' || array_to_string(tags, ' '))) stored,
|
||||
-- Generated expressions may only call IMMUTABLE functions. PostgreSQL marks
|
||||
-- array_to_string as STABLE, so tags stay queryable through their text[]
|
||||
-- column while the FTS document indexes the entity's searchable prose.
|
||||
search_document tsvector generated always as (to_tsvector('english'::regconfig, coalesce(name, '') || ' ' || coalesce(summary, ''))) stored,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create index world_entities_search_idx on public.world_entities using gin(search_document);
|
||||
@@ -175,7 +178,7 @@ create table public.memories (
|
||||
importance integer not null check (importance between 1 and 5),
|
||||
tags text[] not null default '{}',
|
||||
entity_ids uuid[] not null default '{}',
|
||||
search_document tsvector generated always as (to_tsvector('english', coalesce(summary, '') || ' ' || array_to_string(tags, ' '))) stored,
|
||||
search_document tsvector generated always as (to_tsvector('english'::regconfig, coalesce(summary, ''))) stored,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create index memories_search_idx on public.memories using gin(search_document);
|
||||
|
||||
384
supabase/migrations/0003_stage_two_multiplayer.sql
Normal file
384
supabase/migrations/0003_stage_two_multiplayer.sql
Normal file
@@ -0,0 +1,384 @@
|
||||
-- Stage two multiplayer primitives. All mutating RPCs are service-role only;
|
||||
-- Nitro authenticates the caller and passes the verified auth.users id.
|
||||
|
||||
alter table public.coauthor_sessions
|
||||
add column if not exists confirmed_world_id uuid references public.worlds(id) on delete set null;
|
||||
alter table public.worlds add column if not exists hook text;
|
||||
alter table public.worlds add column if not exists opening_scene text;
|
||||
|
||||
create index if not exists coauthor_sessions_owner_updated_idx
|
||||
on public.coauthor_sessions(owner_id, updated_at desc);
|
||||
create index if not exists invites_campaign_expiry_idx
|
||||
on public.invites(campaign_id, expires_at);
|
||||
create unique index if not exists ai_jobs_world_generation_unique
|
||||
on public.ai_jobs(entity_id) where job_type = 'generate-world';
|
||||
|
||||
create or replace function public.stage_two_append_coauthor_message(
|
||||
p_session_id uuid,
|
||||
p_owner_id uuid,
|
||||
p_content text
|
||||
) returns jsonb language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_session public.coauthor_sessions%rowtype;
|
||||
v_result jsonb;
|
||||
begin
|
||||
if p_content is null or char_length(btrim(p_content)) not between 1 and 5000 then
|
||||
raise exception 'message must be between 1 and 5000 characters';
|
||||
end if;
|
||||
select * into v_session from public.coauthor_sessions
|
||||
where id = p_session_id and owner_id = p_owner_id for update;
|
||||
if not found then raise exception 'coauthor session not found'; end if;
|
||||
if v_session.status in ('generating', 'confirmed') then
|
||||
raise exception 'coauthor session cannot be edited in status %', v_session.status;
|
||||
end if;
|
||||
update public.coauthor_sessions
|
||||
set messages = messages || jsonb_build_array(jsonb_build_object('role', 'user', 'content', btrim(p_content))),
|
||||
generated_world = null,
|
||||
status = 'collecting',
|
||||
updated_at = now()
|
||||
where id = p_session_id
|
||||
returning jsonb_build_object('id', id, 'status', status, 'messages', messages, 'updatedAt', updated_at)
|
||||
into v_result;
|
||||
return v_result;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_two_append_coauthor_assistant_message(
|
||||
p_session_id uuid,
|
||||
p_owner_id uuid,
|
||||
p_content text
|
||||
) returns void language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
if p_content is null or char_length(btrim(p_content)) not between 1 and 5000 then
|
||||
raise exception 'assistant message must be between 1 and 5000 characters';
|
||||
end if;
|
||||
update public.coauthor_sessions
|
||||
set messages = messages || jsonb_build_array(jsonb_build_object('role', 'assistant', 'content', btrim(p_content))),
|
||||
updated_at = now()
|
||||
where id = p_session_id and owner_id = p_owner_id and status = 'collecting';
|
||||
if not found then raise exception 'collecting coauthor session not found'; end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_two_open_next_round()
|
||||
returns trigger language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
if old.status is distinct from new.status
|
||||
and new.status in ('resolved'::public.round_status, 'failed'::public.round_status) then
|
||||
update public.characters
|
||||
set controller = 'human'::public.character_controller
|
||||
where campaign_id = new.campaign_id
|
||||
and controller = 'delegated'::public.character_controller
|
||||
and user_id is not null;
|
||||
if new.status = 'resolved'::public.round_status
|
||||
and exists (select 1 from public.campaigns where id = new.campaign_id and status = 'active') then
|
||||
insert into public.rounds(campaign_id, number, status)
|
||||
values (new.campaign_id, new.number + 1, 'open')
|
||||
on conflict (campaign_id, number) do nothing;
|
||||
end if;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists stage_two_round_resolved_open_next on public.rounds;
|
||||
create trigger stage_two_round_resolved_open_next
|
||||
after update of status on public.rounds
|
||||
for each row execute function public.stage_two_open_next_round();
|
||||
|
||||
insert into public.rounds(campaign_id, number, status)
|
||||
select latest.campaign_id, latest.number + 1, 'open'
|
||||
from public.rounds latest
|
||||
join public.campaigns campaign on campaign.id = latest.campaign_id and campaign.status = 'active'
|
||||
where latest.status = 'resolved'
|
||||
and not exists (
|
||||
select 1 from public.rounds newer
|
||||
where newer.campaign_id = latest.campaign_id and newer.number > latest.number
|
||||
)
|
||||
on conflict (campaign_id, number) do nothing;
|
||||
|
||||
create or replace function public.stage_two_upsert_story_summary(
|
||||
p_job_id uuid,
|
||||
p_campaign_id uuid,
|
||||
p_through_round integer,
|
||||
p_summary text
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_summary_id uuid;
|
||||
begin
|
||||
if p_through_round < 1 or p_summary is null or char_length(btrim(p_summary)) not between 20 and 6000 then
|
||||
raise exception 'invalid story summary';
|
||||
end if;
|
||||
if not exists (
|
||||
select 1
|
||||
from public.ai_jobs job
|
||||
join public.rounds round on round.id = job.entity_id
|
||||
where job.id = p_job_id
|
||||
and job.job_type = 'resolve-round'
|
||||
and job.status = 'complete'
|
||||
and round.campaign_id = p_campaign_id
|
||||
and round.number = p_through_round
|
||||
and round.status = 'resolved'
|
||||
) then
|
||||
raise exception 'completed round job not found';
|
||||
end if;
|
||||
insert into public.story_summaries(campaign_id, through_round, summary)
|
||||
values (p_campaign_id, p_through_round, btrim(p_summary))
|
||||
on conflict (campaign_id, through_round) do update set summary = excluded.summary
|
||||
returning id into v_summary_id;
|
||||
return v_summary_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_two_enqueue_world_generation(
|
||||
p_session_id uuid,
|
||||
p_owner_id uuid
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_session public.coauthor_sessions%rowtype;
|
||||
v_job public.ai_jobs%rowtype;
|
||||
begin
|
||||
select * into v_session from public.coauthor_sessions
|
||||
where id = p_session_id and owner_id = p_owner_id for update;
|
||||
if not found then raise exception 'coauthor session not found'; end if;
|
||||
if v_session.status = 'confirmed' then raise exception 'coauthor session is already confirmed'; end if;
|
||||
if jsonb_array_length(v_session.messages) < 1 then raise exception 'at least one message is required'; end if;
|
||||
|
||||
select * into v_job from public.ai_jobs
|
||||
where job_type = 'generate-world' and entity_id = p_session_id for update;
|
||||
if found then
|
||||
if v_job.status in ('queued', 'running') then return v_job.id; end if;
|
||||
update public.ai_jobs
|
||||
set status = 'queued', attempts = 0, claimed_by = null, lease_expires_at = null,
|
||||
available_at = now(), error = null, updated_at = now()
|
||||
where id = v_job.id;
|
||||
else
|
||||
v_job.id := gen_random_uuid();
|
||||
insert into public.ai_jobs(id, job_type, entity_id, idempotency_key, status)
|
||||
values (v_job.id, 'generate-world', p_session_id, v_job.id::text, 'queued');
|
||||
end if;
|
||||
update public.coauthor_sessions
|
||||
set status = 'generating', generated_world = null, updated_at = now()
|
||||
where id = p_session_id;
|
||||
return v_job.id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_two_confirm_world(
|
||||
p_session_id uuid,
|
||||
p_owner_id uuid
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_session public.coauthor_sessions%rowtype;
|
||||
v_world jsonb;
|
||||
v_world_id uuid;
|
||||
v_entity jsonb;
|
||||
begin
|
||||
select * into v_session from public.coauthor_sessions
|
||||
where id = p_session_id and owner_id = p_owner_id for update;
|
||||
if not found then raise exception 'coauthor session not found'; end if;
|
||||
if v_session.status = 'confirmed' and v_session.confirmed_world_id is not null then
|
||||
return v_session.confirmed_world_id;
|
||||
end if;
|
||||
if v_session.status <> 'ready' or v_session.generated_world is null then
|
||||
raise exception 'coauthor session is not ready to confirm';
|
||||
end if;
|
||||
v_world := v_session.generated_world;
|
||||
if coalesce(v_world->>'title', '') = '' or coalesce(v_world->>'openingScene', '') = '' then
|
||||
raise exception 'generated world is incomplete';
|
||||
end if;
|
||||
|
||||
insert into public.worlds(
|
||||
owner_id, title, genre, tone, premise, content_boundaries,
|
||||
hidden_threat, hook, opening_scene, status
|
||||
) values (
|
||||
p_owner_id, v_world->>'title', v_world->>'genre', v_world->>'tone', v_world->>'premise',
|
||||
coalesce(v_world->'contentBoundaries', '[]'::jsonb), v_world->>'hiddenThreat',
|
||||
v_world->>'hook', v_world->>'openingScene', 'confirmed'
|
||||
) returning id into v_world_id;
|
||||
|
||||
v_entity := v_world->'startingLocation';
|
||||
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||
values (v_world_id, 'location', v_entity->>'name', v_entity->>'summary',
|
||||
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
|
||||
coalesce(v_entity->'secrets', '[]'::jsonb));
|
||||
for v_entity in select * from jsonb_array_elements(v_world->'npcs') loop
|
||||
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||
values (v_world_id, 'npc', v_entity->>'name', v_entity->>'summary',
|
||||
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
|
||||
coalesce(v_entity->'secrets', '[]'::jsonb));
|
||||
end loop;
|
||||
for v_entity in select * from jsonb_array_elements(v_world->'factions') loop
|
||||
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||
values (v_world_id, 'faction', v_entity->>'name', v_entity->>'summary',
|
||||
array(select jsonb_array_elements_text(coalesce(v_entity->'tags', '[]'::jsonb))),
|
||||
coalesce(v_entity->'secrets', '[]'::jsonb));
|
||||
end loop;
|
||||
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
|
||||
values (v_world_id, 'quest', 'Opening Hook', v_world->>'hook', array['opening'], '[]'::jsonb);
|
||||
|
||||
update public.coauthor_sessions
|
||||
set status = 'confirmed', confirmed_world_id = v_world_id, updated_at = now()
|
||||
where id = p_session_id;
|
||||
return v_world_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_two_create_campaign(
|
||||
p_world_id uuid,
|
||||
p_owner_id uuid,
|
||||
p_title text
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_world public.worlds%rowtype;
|
||||
v_campaign_id uuid;
|
||||
begin
|
||||
if p_title is null or char_length(btrim(p_title)) not between 3 and 100 then
|
||||
raise exception 'campaign title must be between 3 and 100 characters';
|
||||
end if;
|
||||
select * into v_world from public.worlds
|
||||
where id = p_world_id and owner_id = p_owner_id and status = 'confirmed' for share;
|
||||
if not found then raise exception 'confirmed world not found'; end if;
|
||||
insert into public.campaigns(world_id, owner_id, title, current_scene, next_prompt, status)
|
||||
values (p_world_id, p_owner_id, btrim(p_title), coalesce(v_world.opening_scene, v_world.premise), 'What do you do?', 'active')
|
||||
returning id into v_campaign_id;
|
||||
insert into public.campaign_members(campaign_id, user_id, role)
|
||||
values (v_campaign_id, p_owner_id, 'owner');
|
||||
insert into public.rounds(campaign_id, number) values (v_campaign_id, 1);
|
||||
return v_campaign_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_two_join_campaign(
|
||||
p_token_hash text,
|
||||
p_user_id uuid
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_invite public.invites%rowtype;
|
||||
v_campaign_id uuid;
|
||||
begin
|
||||
if p_token_hash is null or char_length(p_token_hash) <> 64 then raise exception 'invalid invite token'; end if;
|
||||
select * into v_invite from public.invites where token_hash = p_token_hash for update;
|
||||
if not found or v_invite.expires_at <= now() or v_invite.uses >= v_invite.max_uses then
|
||||
raise exception 'invite is invalid or expired';
|
||||
end if;
|
||||
if not exists (select 1 from public.profiles where id = p_user_id) then raise exception 'profile not found'; end if;
|
||||
if not exists (select 1 from public.campaigns where id = v_invite.campaign_id and status <> 'archived') then
|
||||
raise exception 'campaign is not available';
|
||||
end if;
|
||||
v_campaign_id := v_invite.campaign_id;
|
||||
if exists (select 1 from public.campaign_members where campaign_id = v_campaign_id and user_id = p_user_id) then
|
||||
update public.campaign_members set active = true where campaign_id = v_campaign_id and user_id = p_user_id;
|
||||
return v_campaign_id;
|
||||
end if;
|
||||
insert into public.campaign_members(campaign_id, user_id, role) values (v_campaign_id, p_user_id, 'player');
|
||||
update public.invites set uses = uses + 1 where id = v_invite.id;
|
||||
return v_campaign_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_two_submit_intent(
|
||||
p_round_id uuid,
|
||||
p_user_id uuid,
|
||||
p_character_id uuid,
|
||||
p_action text,
|
||||
p_ready boolean default false
|
||||
) returns jsonb language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_round public.rounds%rowtype;
|
||||
v_member public.campaign_members%rowtype;
|
||||
v_intent_id uuid;
|
||||
v_job_id uuid;
|
||||
begin
|
||||
if p_action is null or char_length(btrim(p_action)) not between 1 and 2000 then
|
||||
raise exception 'action must be between 1 and 2000 characters';
|
||||
end if;
|
||||
select * into v_round from public.rounds where id = p_round_id for update;
|
||||
if not found or v_round.status <> 'open' then raise exception 'round is not open'; end if;
|
||||
select * into v_member from public.campaign_members
|
||||
where campaign_id = v_round.campaign_id and user_id = p_user_id and active;
|
||||
if not found then raise exception 'active campaign membership not found'; end if;
|
||||
if not exists (
|
||||
select 1 from public.characters
|
||||
where id = p_character_id and campaign_id = v_round.campaign_id
|
||||
and user_id = p_user_id and controller = 'human'
|
||||
) then raise exception 'controlled character not found'; end if;
|
||||
|
||||
insert into public.player_intents(round_id, member_id, character_id, action, ready)
|
||||
values (p_round_id, v_member.id, p_character_id, btrim(p_action), coalesce(p_ready, false))
|
||||
on conflict (round_id, member_id) do update
|
||||
set character_id = excluded.character_id, action = excluded.action,
|
||||
ready = excluded.ready, updated_at = now()
|
||||
returning id into v_intent_id;
|
||||
|
||||
if coalesce(p_ready, false) and not exists (
|
||||
select 1 from public.campaign_members member
|
||||
where member.campaign_id = v_round.campaign_id and member.active
|
||||
and not exists (
|
||||
select 1 from public.player_intents intent
|
||||
where intent.round_id = p_round_id and intent.member_id = member.id and intent.ready
|
||||
)
|
||||
) then
|
||||
v_job_id := public.enqueue_round_resolution(p_round_id, null);
|
||||
end if;
|
||||
return jsonb_build_object('intentId', v_intent_id, 'jobId', v_job_id);
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.stage_two_force_round(
|
||||
p_round_id uuid,
|
||||
p_owner_id uuid
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_round public.rounds%rowtype;
|
||||
v_job_id uuid;
|
||||
begin
|
||||
select * into v_round from public.rounds where id = p_round_id for update;
|
||||
if not found or v_round.status <> 'open' then raise exception 'round is not open'; end if;
|
||||
if not exists (
|
||||
select 1 from public.campaigns
|
||||
where id = v_round.campaign_id and owner_id = p_owner_id
|
||||
) then raise exception 'only the campaign owner can force a round'; end if;
|
||||
|
||||
update public.characters character
|
||||
set controller = 'delegated'::public.character_controller
|
||||
from public.campaign_members member
|
||||
where character.campaign_id = v_round.campaign_id
|
||||
and character.user_id = member.user_id
|
||||
and character.controller = 'human'::public.character_controller
|
||||
and member.campaign_id = v_round.campaign_id
|
||||
and member.active
|
||||
and member.ai_takeover_allowed
|
||||
and not exists (
|
||||
select 1 from public.player_intents intent
|
||||
where intent.round_id = p_round_id
|
||||
and intent.member_id = member.id
|
||||
and intent.ready
|
||||
);
|
||||
|
||||
v_job_id := public.enqueue_round_resolution(p_round_id, p_owner_id);
|
||||
return v_job_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.stage_two_append_coauthor_message(uuid, uuid, text) from public, anon, authenticated;
|
||||
revoke all on function public.stage_two_append_coauthor_assistant_message(uuid, uuid, text) from public, anon, authenticated;
|
||||
revoke all on function public.stage_two_enqueue_world_generation(uuid, uuid) from public, anon, authenticated;
|
||||
revoke all on function public.stage_two_confirm_world(uuid, uuid) from public, anon, authenticated;
|
||||
revoke all on function public.stage_two_create_campaign(uuid, uuid, text) from public, anon, authenticated;
|
||||
revoke all on function public.stage_two_join_campaign(text, uuid) from public, anon, authenticated;
|
||||
revoke all on function public.stage_two_submit_intent(uuid, uuid, uuid, text, boolean) from public, anon, authenticated;
|
||||
revoke all on function public.stage_two_force_round(uuid, uuid) from public, anon, authenticated;
|
||||
revoke all on function public.stage_two_open_next_round() from public, anon, authenticated;
|
||||
revoke all on function public.stage_two_upsert_story_summary(uuid, uuid, integer, text) from public, anon, authenticated;
|
||||
grant execute on function public.stage_two_append_coauthor_message(uuid, uuid, text) to service_role;
|
||||
grant execute on function public.stage_two_append_coauthor_assistant_message(uuid, uuid, text) to service_role;
|
||||
grant execute on function public.stage_two_enqueue_world_generation(uuid, uuid) to service_role;
|
||||
grant execute on function public.stage_two_confirm_world(uuid, uuid) to service_role;
|
||||
grant execute on function public.stage_two_create_campaign(uuid, uuid, text) to service_role;
|
||||
grant execute on function public.stage_two_join_campaign(text, uuid) to service_role;
|
||||
grant execute on function public.stage_two_submit_intent(uuid, uuid, uuid, text, boolean) to service_role;
|
||||
grant execute on function public.stage_two_force_round(uuid, uuid) to service_role;
|
||||
grant execute on function public.stage_two_upsert_story_summary(uuid, uuid, integer, text) to service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
100
supabase/migrations/0004_anonymous_alpha_access.sql
Normal file
100
supabase/migrations/0004_anonymous_alpha_access.sql
Normal file
@@ -0,0 +1,100 @@
|
||||
-- Guest access still uses a real authenticated Supabase user. This keeps the
|
||||
-- existing ownership checks, RLS policies, and multiplayer membership model.
|
||||
-- Email accounts remain restricted to the allowlist.
|
||||
|
||||
create or replace function public.create_profile_for_allowlisted_user()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_is_anonymous boolean := coalesce(new.is_anonymous, false);
|
||||
v_display_name text;
|
||||
begin
|
||||
if not v_is_anonymous and (
|
||||
new.email is null or not exists (
|
||||
select 1
|
||||
from public.allowlist
|
||||
where email = lower(btrim(new.email))
|
||||
)
|
||||
) then
|
||||
raise exception using
|
||||
errcode = '42501',
|
||||
message = 'This email is not invited to the Dungeons & Ground alpha.';
|
||||
end if;
|
||||
|
||||
v_display_name := case
|
||||
when v_is_anonymous then 'Guest ' || upper(substr(replace(new.id::text, '-', ''), 1, 6))
|
||||
else coalesce(nullif(btrim(new.raw_user_meta_data ->> 'display_name'), ''), 'Adventurer')
|
||||
end;
|
||||
|
||||
insert into public.profiles(id, display_name)
|
||||
values (new.id, v_display_name)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.create_profile_for_allowlisted_user() from public;
|
||||
|
||||
-- An anonymous account can be upgraded later. Recheck the allowlist on the
|
||||
-- relevant auth.users transition so linking an email cannot bypass the alpha
|
||||
-- gate. Unrelated token and metadata updates do not fire this trigger.
|
||||
create or replace function public.enforce_alpha_email_allowlist()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
if not coalesce(new.is_anonymous, false) and (
|
||||
new.email is null or not exists (
|
||||
select 1
|
||||
from public.allowlist
|
||||
where email = lower(btrim(new.email))
|
||||
)
|
||||
) then
|
||||
raise exception using
|
||||
errcode = '42501',
|
||||
message = 'This email is not invited to the Dungeons & Ground alpha.';
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.enforce_alpha_email_allowlist() from public;
|
||||
|
||||
drop trigger if exists enforce_alpha_email_allowlist on auth.users;
|
||||
create trigger enforce_alpha_email_allowlist
|
||||
before update of email, is_anonymous on auth.users
|
||||
for each row
|
||||
when (
|
||||
old.email is distinct from new.email
|
||||
or old.is_anonymous is distinct from new.is_anonymous
|
||||
)
|
||||
execute function public.enforce_alpha_email_allowlist();
|
||||
|
||||
-- The schema may be installed after Auth users already exist. Backfill only
|
||||
-- anonymous users and explicitly allowlisted email accounts; do not silently
|
||||
-- enable unrelated accounts.
|
||||
insert into public.profiles(id, display_name)
|
||||
select
|
||||
auth_user.id,
|
||||
case
|
||||
when coalesce(auth_user.is_anonymous, false)
|
||||
then 'Guest ' || upper(substr(replace(auth_user.id::text, '-', ''), 1, 6))
|
||||
else coalesce(nullif(btrim(auth_user.raw_user_meta_data ->> 'display_name'), ''), 'Adventurer')
|
||||
end
|
||||
from auth.users auth_user
|
||||
where coalesce(auth_user.is_anonymous, false)
|
||||
or exists (
|
||||
select 1
|
||||
from public.allowlist
|
||||
where allowlist.email = lower(btrim(auth_user.email))
|
||||
)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
83
supabase/migrations/0005_failed_round_retry.sql
Normal file
83
supabase/migrations/0005_failed_round_retry.sql
Normal file
@@ -0,0 +1,83 @@
|
||||
-- Owners can safely retry a round after all worker attempts have failed.
|
||||
-- The queue has one durable outbox row per round. A retry records the failed
|
||||
-- state in the audit log, then safely re-queues that same row.
|
||||
create or replace function public.stage_two_retry_failed_round(
|
||||
p_round_id uuid,
|
||||
p_owner_id uuid
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_round public.rounds%rowtype;
|
||||
v_job public.ai_jobs%rowtype;
|
||||
begin
|
||||
select * into v_round
|
||||
from public.rounds
|
||||
where id = p_round_id
|
||||
for update;
|
||||
|
||||
if not found then raise exception 'round not found'; end if;
|
||||
if v_round.status <> 'failed'::public.round_status then
|
||||
raise exception 'round is not failed';
|
||||
end if;
|
||||
if not exists (
|
||||
select 1 from public.campaigns
|
||||
where id = v_round.campaign_id
|
||||
and owner_id = p_owner_id
|
||||
and status = 'active'
|
||||
) then
|
||||
raise exception 'only the campaign owner can retry a failed round';
|
||||
end if;
|
||||
if exists (
|
||||
select 1 from public.rounds
|
||||
where campaign_id = v_round.campaign_id
|
||||
and id <> p_round_id
|
||||
and status in ('open'::public.round_status, 'queued'::public.round_status, 'resolving'::public.round_status)
|
||||
) then
|
||||
raise exception 'campaign already has an active round';
|
||||
end if;
|
||||
|
||||
select * into v_job
|
||||
from public.ai_jobs
|
||||
where job_type = 'resolve-round' and entity_id = p_round_id
|
||||
for update;
|
||||
if not found or v_job.status <> 'failed' then
|
||||
raise exception 'failed round job not found';
|
||||
end if;
|
||||
|
||||
insert into public.audit_entries(
|
||||
campaign_id, actor_id, action, entity_type, entity_id, before_state, after_state
|
||||
) values (
|
||||
v_round.campaign_id,
|
||||
p_owner_id,
|
||||
'retry_failed_round',
|
||||
'round',
|
||||
p_round_id,
|
||||
jsonb_build_object('roundError', v_round.error, 'jobError', v_job.error, 'attempts', v_job.attempts),
|
||||
jsonb_build_object('status', 'queued')
|
||||
);
|
||||
|
||||
update public.ai_jobs
|
||||
set status = 'queued',
|
||||
attempts = 0,
|
||||
claimed_by = null,
|
||||
lease_expires_at = null,
|
||||
available_at = now(),
|
||||
error = null,
|
||||
updated_at = now()
|
||||
where id = v_job.id;
|
||||
|
||||
update public.rounds
|
||||
set status = 'queued'::public.round_status,
|
||||
error = null,
|
||||
queued_at = now(),
|
||||
resolved_at = null,
|
||||
forced_by = p_owner_id
|
||||
where id = p_round_id;
|
||||
|
||||
return v_job.id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.stage_two_retry_failed_round(uuid, uuid) from public, anon, authenticated;
|
||||
grant execute on function public.stage_two_retry_failed_round(uuid, uuid) to service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
63
supabase/migrations/0006_retry_job_compatibility.sql
Normal file
63
supabase/migrations/0006_retry_job_compatibility.sql
Normal file
@@ -0,0 +1,63 @@
|
||||
-- Replace the first retry implementation for installations that already ran
|
||||
-- migration 0005 against the one-job-per-round queue constraint.
|
||||
create or replace function public.stage_two_retry_failed_round(
|
||||
p_round_id uuid,
|
||||
p_owner_id uuid
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_round public.rounds%rowtype;
|
||||
v_job public.ai_jobs%rowtype;
|
||||
begin
|
||||
select * into v_round from public.rounds where id = p_round_id for update;
|
||||
if not found then raise exception 'round not found'; end if;
|
||||
if v_round.status <> 'failed'::public.round_status then raise exception 'round is not failed'; end if;
|
||||
if not exists (
|
||||
select 1 from public.campaigns
|
||||
where id = v_round.campaign_id and owner_id = p_owner_id and status = 'active'
|
||||
) then raise exception 'only the campaign owner can retry a failed round'; end if;
|
||||
if exists (
|
||||
select 1 from public.rounds
|
||||
where campaign_id = v_round.campaign_id
|
||||
and id <> p_round_id
|
||||
and status in ('open'::public.round_status, 'queued'::public.round_status, 'resolving'::public.round_status)
|
||||
) then raise exception 'campaign already has an active round'; end if;
|
||||
|
||||
select * into v_job
|
||||
from public.ai_jobs
|
||||
where job_type = 'resolve-round' and entity_id = p_round_id
|
||||
for update;
|
||||
if not found or v_job.status <> 'failed' then raise exception 'failed round job not found'; end if;
|
||||
|
||||
insert into public.audit_entries(
|
||||
campaign_id, actor_id, action, entity_type, entity_id, before_state, after_state
|
||||
) values (
|
||||
v_round.campaign_id, p_owner_id, 'retry_failed_round', 'round', p_round_id,
|
||||
jsonb_build_object('roundError', v_round.error, 'jobError', v_job.error, 'attempts', v_job.attempts),
|
||||
jsonb_build_object('status', 'queued')
|
||||
);
|
||||
|
||||
update public.ai_jobs
|
||||
set status = 'queued', attempts = 0, claimed_by = null, lease_expires_at = null,
|
||||
available_at = now(), error = null, updated_at = now()
|
||||
where id = v_job.id;
|
||||
|
||||
update public.rounds
|
||||
set status = 'queued'::public.round_status, error = null, queued_at = now(),
|
||||
resolved_at = null, forced_by = p_owner_id
|
||||
where id = p_round_id;
|
||||
|
||||
return v_job.id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.dng_schema_version()
|
||||
returns integer language sql stable security definer set search_path = '' as $$
|
||||
select 6;
|
||||
$$;
|
||||
|
||||
revoke all on function public.stage_two_retry_failed_round(uuid, uuid) from public, anon, authenticated;
|
||||
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
||||
grant execute on function public.stage_two_retry_failed_round(uuid, uuid) to service_role;
|
||||
grant execute on function public.dng_schema_version() to service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user