This commit is contained in:
852
supabase/migrations/0001_alpha_schema.sql
Normal file
852
supabase/migrations/0001_alpha_schema.sql
Normal file
@@ -0,0 +1,852 @@
|
||||
create extension if not exists pgcrypto;
|
||||
|
||||
create type public.member_role as enum ('owner', 'player');
|
||||
create type public.character_controller as enum ('human', 'ai', 'delegated');
|
||||
create type public.round_status as enum ('open', 'queued', 'resolving', 'resolved', 'failed');
|
||||
|
||||
create table public.allowlist (
|
||||
email text primary key check (email <> '' and email = lower(btrim(email))),
|
||||
invited_by uuid references auth.users(id) on delete set null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table public.profiles (
|
||||
id uuid primary key references auth.users(id) on delete cascade,
|
||||
display_name text not null default 'Adventurer' check (char_length(display_name) between 1 and 80),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table public.worlds (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
owner_id uuid not null references public.profiles(id),
|
||||
title text not null check (char_length(title) between 3 and 100),
|
||||
genre text not null,
|
||||
tone text not null,
|
||||
premise text not null,
|
||||
content_boundaries jsonb not null default '[]' check (jsonb_typeof(content_boundaries) = 'array'),
|
||||
hidden_threat text not null,
|
||||
status text not null default 'draft' check (status in ('draft', 'confirmed')),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table public.world_entities (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
world_id uuid not null references public.worlds(id) on delete cascade,
|
||||
kind text not null check (kind in ('location', 'npc', 'faction', 'quest')),
|
||||
name text not null,
|
||||
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,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create index world_entities_search_idx on public.world_entities using gin(search_document);
|
||||
|
||||
create table public.coauthor_sessions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
owner_id uuid not null references public.profiles(id),
|
||||
status text not null default 'collecting' check (status in ('collecting', 'generating', 'ready', 'failed', 'confirmed')),
|
||||
messages jsonb not null default '[]' check (jsonb_typeof(messages) = 'array'),
|
||||
generated_world jsonb check (generated_world is null or jsonb_typeof(generated_world) = 'object'),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table public.campaigns (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
world_id uuid not null references public.worlds(id),
|
||||
owner_id uuid not null references public.profiles(id),
|
||||
title text not null,
|
||||
current_scene text not null,
|
||||
next_prompt text not null default 'What do you do?',
|
||||
status text not null default 'lobby' check (status in ('lobby', 'active', 'archived')),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table public.campaign_members (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
campaign_id uuid not null references public.campaigns(id) on delete cascade,
|
||||
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||
role public.member_role not null default 'player',
|
||||
active boolean not null default true,
|
||||
ai_takeover_allowed boolean not null default false,
|
||||
joined_at timestamptz not null default now(),
|
||||
unique (campaign_id, user_id)
|
||||
);
|
||||
|
||||
create table public.invites (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
campaign_id uuid not null references public.campaigns(id) on delete cascade,
|
||||
created_by uuid not null references public.profiles(id),
|
||||
token_hash text not null unique,
|
||||
max_uses integer not null default 1 check (max_uses between 1 and 20),
|
||||
uses integer not null default 0 check (uses >= 0 and uses <= max_uses),
|
||||
expires_at timestamptz not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table public.characters (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
campaign_id uuid not null references public.campaigns(id) on delete cascade,
|
||||
user_id uuid references public.profiles(id) on delete set null,
|
||||
name text not null,
|
||||
concept text not null,
|
||||
controller public.character_controller not null,
|
||||
abilities jsonb not null check (jsonb_typeof(abilities) = 'object'),
|
||||
hp integer not null check (hp >= 0),
|
||||
max_hp integer not null check (max_hp > 0),
|
||||
defense integer not null check (defense between 1 and 40),
|
||||
proficiency integer not null default 2 check (proficiency between 1 and 10),
|
||||
inventory jsonb not null default '[]' check (jsonb_typeof(inventory) = 'array'),
|
||||
statuses jsonb not null default '[]' check (jsonb_typeof(statuses) = 'array'),
|
||||
persona jsonb not null default '{}' check (jsonb_typeof(persona) = 'object'),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
alter table public.characters add constraint characters_hp_within_max check (hp <= max_hp);
|
||||
|
||||
create table public.rounds (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
campaign_id uuid not null references public.campaigns(id) on delete cascade,
|
||||
number integer not null check (number > 0),
|
||||
status public.round_status not null default 'open',
|
||||
forced_by uuid references public.profiles(id),
|
||||
narration text,
|
||||
next_prompt text,
|
||||
queued_at timestamptz,
|
||||
resolved_at timestamptz,
|
||||
error text,
|
||||
created_at timestamptz not null default now(),
|
||||
unique (campaign_id, number)
|
||||
);
|
||||
|
||||
create table public.player_intents (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
round_id uuid not null references public.rounds(id) on delete cascade,
|
||||
member_id uuid not null references public.campaign_members(id) on delete cascade,
|
||||
character_id uuid not null references public.characters(id),
|
||||
action text not null check (char_length(action) between 1 and 2000),
|
||||
ready boolean not null default false,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
unique (round_id, member_id)
|
||||
);
|
||||
|
||||
create table public.game_events (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
campaign_id uuid not null references public.campaigns(id) on delete cascade,
|
||||
round_id uuid references public.rounds(id),
|
||||
event_type text not null,
|
||||
payload jsonb not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table public.dice_rolls (
|
||||
id uuid primary key,
|
||||
round_id uuid not null references public.rounds(id) on delete cascade,
|
||||
actor_id uuid not null references public.characters(id),
|
||||
target_id uuid references public.characters(id),
|
||||
check_kind text not null,
|
||||
formula text not null,
|
||||
rolls integer[] not null,
|
||||
kept integer[] not null,
|
||||
modifier integer not null,
|
||||
total integer not null,
|
||||
difficulty integer,
|
||||
success boolean,
|
||||
created_at timestamptz not null
|
||||
);
|
||||
|
||||
create table public.relationships (
|
||||
campaign_id uuid not null references public.campaigns(id) on delete cascade,
|
||||
source_entity_id uuid not null,
|
||||
target_entity_id uuid not null,
|
||||
score integer not null default 0 check (score between -100 and 100),
|
||||
notes text not null default '',
|
||||
primary key (campaign_id, source_entity_id, target_entity_id)
|
||||
);
|
||||
|
||||
create table public.memories (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
campaign_id uuid not null references public.campaigns(id) on delete cascade,
|
||||
round_id uuid references public.rounds(id),
|
||||
summary text not null,
|
||||
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,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create index memories_search_idx on public.memories using gin(search_document);
|
||||
|
||||
create table public.story_summaries (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
campaign_id uuid not null references public.campaigns(id) on delete cascade,
|
||||
through_round integer not null,
|
||||
summary text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
unique (campaign_id, through_round)
|
||||
);
|
||||
|
||||
create table public.ai_jobs (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
job_type text not null,
|
||||
entity_id uuid not null,
|
||||
idempotency_key text not null unique,
|
||||
status text not null default 'queued' check (status in ('queued', 'running', 'complete', 'failed')),
|
||||
attempts integer not null default 0 check (attempts >= 0),
|
||||
claimed_by text,
|
||||
lease_expires_at timestamptz,
|
||||
available_at timestamptz not null default now(),
|
||||
max_attempts integer not null default 3 check (max_attempts between 1 and 10),
|
||||
error text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
check (
|
||||
(status = 'running' and claimed_by is not null and lease_expires_at is not null)
|
||||
or (status <> 'running' and claimed_by is null and lease_expires_at is null)
|
||||
)
|
||||
);
|
||||
create unique index ai_jobs_round_resolution_unique
|
||||
on public.ai_jobs(entity_id) where job_type = 'resolve-round';
|
||||
create index ai_jobs_poll_idx
|
||||
on public.ai_jobs(available_at, created_at) where status in ('queued', 'running');
|
||||
create index ai_jobs_expired_lease_idx
|
||||
on public.ai_jobs(lease_expires_at) where status = 'running';
|
||||
|
||||
create table public.ai_usage (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid references public.profiles(id),
|
||||
campaign_id uuid references public.campaigns(id),
|
||||
job_id uuid references public.ai_jobs(id),
|
||||
model text not null,
|
||||
input_tokens integer not null default 0 check (input_tokens >= 0),
|
||||
output_tokens integer not null default 0 check (output_tokens >= 0),
|
||||
cost_usd numeric(12, 6) not null default 0 check (cost_usd >= 0),
|
||||
latency_ms integer check (latency_ms is null or latency_ms >= 0),
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create table public.audit_entries (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
campaign_id uuid not null references public.campaigns(id) on delete cascade,
|
||||
actor_id uuid references public.profiles(id),
|
||||
action text not null,
|
||||
entity_type text not null,
|
||||
entity_id uuid not null,
|
||||
before_state jsonb,
|
||||
after_state jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index campaigns_owner_idx on public.campaigns(owner_id);
|
||||
create index campaign_members_user_idx on public.campaign_members(user_id, campaign_id) where active;
|
||||
create index characters_campaign_idx on public.characters(campaign_id);
|
||||
create index rounds_campaign_status_idx on public.rounds(campaign_id, status);
|
||||
create index player_intents_round_ready_idx on public.player_intents(round_id, ready);
|
||||
create index game_events_campaign_round_idx on public.game_events(campaign_id, round_id);
|
||||
create index memories_campaign_importance_idx on public.memories(campaign_id, importance desc);
|
||||
|
||||
create or replace function public.create_profile_for_allowlisted_user()
|
||||
returns trigger language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
if 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;
|
||||
|
||||
insert into public.profiles(id) values (new.id);
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.create_profile_for_allowlisted_user() from public;
|
||||
|
||||
create trigger create_profile_for_allowlisted_user
|
||||
after insert on auth.users
|
||||
for each row execute function public.create_profile_for_allowlisted_user();
|
||||
|
||||
create or replace function public.is_campaign_member(p_campaign_id uuid)
|
||||
returns boolean language sql stable security definer set search_path = '' as $$
|
||||
select exists(
|
||||
select 1
|
||||
from public.campaign_members
|
||||
where campaign_id = p_campaign_id
|
||||
and user_id = auth.uid()
|
||||
and active
|
||||
);
|
||||
$$;
|
||||
|
||||
create or replace function public.is_campaign_owner(p_campaign_id uuid)
|
||||
returns boolean language sql stable security definer set search_path = '' as $$
|
||||
select exists(
|
||||
select 1
|
||||
from public.campaigns
|
||||
where id = p_campaign_id and owner_id = auth.uid()
|
||||
);
|
||||
$$;
|
||||
|
||||
revoke all on function public.is_campaign_member(uuid) from public;
|
||||
revoke all on function public.is_campaign_owner(uuid) from public;
|
||||
grant execute on function public.is_campaign_member(uuid) to authenticated, service_role;
|
||||
grant execute on function public.is_campaign_owner(uuid) to authenticated, service_role;
|
||||
|
||||
create or replace function public.validate_player_intent_membership()
|
||||
returns trigger language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_round_campaign_id uuid;
|
||||
v_member_campaign_id uuid;
|
||||
v_member_user_id uuid;
|
||||
v_member_active boolean;
|
||||
v_character_campaign_id uuid;
|
||||
v_character_user_id uuid;
|
||||
begin
|
||||
select campaign_id into v_round_campaign_id
|
||||
from public.rounds where id = new.round_id;
|
||||
select campaign_id, user_id, active
|
||||
into v_member_campaign_id, v_member_user_id, v_member_active
|
||||
from public.campaign_members where id = new.member_id;
|
||||
select campaign_id, user_id
|
||||
into v_character_campaign_id, v_character_user_id
|
||||
from public.characters where id = new.character_id;
|
||||
|
||||
if v_round_campaign_id is null
|
||||
or v_member_campaign_id is distinct from v_round_campaign_id
|
||||
or v_character_campaign_id is distinct from v_round_campaign_id
|
||||
or not coalesce(v_member_active, false)
|
||||
or v_character_user_id is distinct from v_member_user_id then
|
||||
raise exception 'intent member and character must belong to the round campaign';
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.validate_player_intent_membership() from public;
|
||||
|
||||
create trigger validate_player_intent_membership
|
||||
before insert or update of round_id, member_id, character_id on public.player_intents
|
||||
for each row execute function public.validate_player_intent_membership();
|
||||
|
||||
alter table public.allowlist enable row level security;
|
||||
alter table public.profiles enable row level security;
|
||||
alter table public.worlds enable row level security;
|
||||
alter table public.world_entities enable row level security;
|
||||
alter table public.coauthor_sessions enable row level security;
|
||||
alter table public.campaigns enable row level security;
|
||||
alter table public.campaign_members enable row level security;
|
||||
alter table public.invites enable row level security;
|
||||
alter table public.characters enable row level security;
|
||||
alter table public.rounds enable row level security;
|
||||
alter table public.player_intents enable row level security;
|
||||
alter table public.game_events enable row level security;
|
||||
alter table public.dice_rolls enable row level security;
|
||||
alter table public.relationships enable row level security;
|
||||
alter table public.memories enable row level security;
|
||||
alter table public.story_summaries enable row level security;
|
||||
alter table public.ai_jobs enable row level security;
|
||||
alter table public.ai_usage enable row level security;
|
||||
alter table public.audit_entries enable row level security;
|
||||
|
||||
create policy profiles_self on public.profiles for select using (id = auth.uid());
|
||||
create policy profiles_self_update on public.profiles for update using (id = auth.uid()) with check (id = auth.uid());
|
||||
create policy worlds_owner_all on public.worlds for all using (owner_id = auth.uid()) with check (owner_id = auth.uid());
|
||||
create policy entities_owner_all on public.world_entities for all using (exists(select 1 from worlds where worlds.id = world_id and worlds.owner_id = auth.uid()));
|
||||
create policy coauthor_owner_all on public.coauthor_sessions for all using (owner_id = auth.uid()) with check (owner_id = auth.uid());
|
||||
create policy campaigns_members_read on public.campaigns for select using (is_campaign_member(id) or owner_id = auth.uid());
|
||||
create policy campaigns_owner_write on public.campaigns for all using (owner_id = auth.uid()) with check (
|
||||
owner_id = auth.uid()
|
||||
and exists (
|
||||
select 1 from public.worlds
|
||||
where worlds.id = world_id and worlds.owner_id = auth.uid() and worlds.status = 'confirmed'
|
||||
)
|
||||
);
|
||||
create policy members_member_read on public.campaign_members for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id));
|
||||
create policy members_owner_insert on public.campaign_members for insert with check (
|
||||
is_campaign_owner(campaign_id)
|
||||
and (
|
||||
(user_id = auth.uid() and role = 'owner')
|
||||
or (user_id <> auth.uid() and role = 'player')
|
||||
)
|
||||
);
|
||||
create policy members_owner_update on public.campaign_members for update using (is_campaign_owner(campaign_id)) with check (
|
||||
is_campaign_owner(campaign_id)
|
||||
and (
|
||||
(user_id = auth.uid() and role = 'owner')
|
||||
or (user_id <> auth.uid() and role = 'player')
|
||||
)
|
||||
);
|
||||
create policy members_owner_delete on public.campaign_members for delete using (is_campaign_owner(campaign_id) and user_id <> auth.uid());
|
||||
create policy characters_member_read on public.characters for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id));
|
||||
create policy characters_member_insert on public.characters for insert with check (
|
||||
(is_campaign_member(campaign_id) and user_id = auth.uid() and controller = 'human')
|
||||
or (is_campaign_owner(campaign_id) and (user_id = auth.uid() or (user_id is null and controller = 'ai')))
|
||||
);
|
||||
create policy rounds_member_read on public.rounds for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id));
|
||||
create policy intents_member_read on public.player_intents for select using (
|
||||
exists(
|
||||
select 1 from public.rounds
|
||||
where rounds.id = round_id
|
||||
and (is_campaign_member(rounds.campaign_id) or is_campaign_owner(rounds.campaign_id))
|
||||
)
|
||||
);
|
||||
create policy intents_self_insert on public.player_intents for insert with check (
|
||||
exists(
|
||||
select 1
|
||||
from public.rounds
|
||||
join public.campaign_members on campaign_members.campaign_id = rounds.campaign_id
|
||||
join public.characters on characters.campaign_id = rounds.campaign_id
|
||||
where rounds.id = round_id
|
||||
and rounds.status = 'open'
|
||||
and campaign_members.id = member_id
|
||||
and campaign_members.user_id = auth.uid()
|
||||
and campaign_members.active
|
||||
and characters.id = character_id
|
||||
and characters.user_id = auth.uid()
|
||||
and characters.controller = 'human'
|
||||
)
|
||||
);
|
||||
create policy intents_self_update on public.player_intents for update using (
|
||||
exists(
|
||||
select 1 from public.campaign_members
|
||||
where campaign_members.id = member_id
|
||||
and campaign_members.user_id = auth.uid()
|
||||
and campaign_members.active
|
||||
)
|
||||
) with check (
|
||||
exists(
|
||||
select 1
|
||||
from public.rounds
|
||||
join public.campaign_members on campaign_members.campaign_id = rounds.campaign_id
|
||||
join public.characters on characters.campaign_id = rounds.campaign_id
|
||||
where rounds.id = round_id
|
||||
and rounds.status = 'open'
|
||||
and campaign_members.id = member_id
|
||||
and campaign_members.user_id = auth.uid()
|
||||
and campaign_members.active
|
||||
and characters.id = character_id
|
||||
and characters.user_id = auth.uid()
|
||||
and characters.controller = 'human'
|
||||
)
|
||||
);
|
||||
create policy events_member_read on public.game_events for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id));
|
||||
create policy rolls_member_read on public.dice_rolls for select using (exists(select 1 from public.rounds where rounds.id = round_id and (is_campaign_member(rounds.campaign_id) or is_campaign_owner(rounds.campaign_id))));
|
||||
create policy relationships_member_read on public.relationships for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id));
|
||||
create policy memories_member_read on public.memories for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id));
|
||||
create policy summaries_member_read on public.story_summaries for select using (is_campaign_member(campaign_id) or is_campaign_owner(campaign_id));
|
||||
create policy usage_self_read on public.ai_usage for select using (user_id = auth.uid());
|
||||
create policy audit_owner_read on public.audit_entries for select using (is_campaign_owner(campaign_id));
|
||||
|
||||
create or replace function public.enqueue_round_resolution(
|
||||
p_round_id uuid,
|
||||
p_forced_by uuid default null
|
||||
) 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 then raise exception 'round not found'; end if;
|
||||
|
||||
select id into v_job_id
|
||||
from public.ai_jobs
|
||||
where job_type = 'resolve-round' and entity_id = p_round_id;
|
||||
|
||||
if v_round.status in ('queued', 'resolving', 'resolved') then
|
||||
if v_job_id is null and v_round.status <> 'resolved' then
|
||||
raise exception 'round status and job outbox are inconsistent';
|
||||
end if;
|
||||
return v_job_id;
|
||||
end if;
|
||||
if v_round.status <> 'open' then
|
||||
raise exception 'round cannot be queued from status %', v_round.status;
|
||||
end if;
|
||||
|
||||
if p_forced_by is not null then
|
||||
if not exists (
|
||||
select 1 from public.campaigns
|
||||
where id = v_round.campaign_id and owner_id = p_forced_by
|
||||
) then
|
||||
raise exception 'only the campaign owner can force a round';
|
||||
end if;
|
||||
elsif 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
|
||||
raise exception 'not all active players are ready';
|
||||
end if;
|
||||
|
||||
v_job_id := gen_random_uuid();
|
||||
insert into public.ai_jobs(id, job_type, entity_id, idempotency_key, status)
|
||||
values (v_job_id, 'resolve-round', p_round_id, v_job_id::text, 'queued');
|
||||
|
||||
update public.rounds
|
||||
set status = 'queued', queued_at = now(), forced_by = p_forced_by, error = null
|
||||
where id = p_round_id;
|
||||
return v_job_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.enqueue_round_resolution(uuid, uuid) from public, anon, authenticated;
|
||||
grant execute on function public.enqueue_round_resolution(uuid, uuid) to service_role;
|
||||
|
||||
create or replace function public.claim_ai_job(
|
||||
p_worker_id text
|
||||
) returns table(id uuid, job_type text, entity_id uuid)
|
||||
language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_job public.ai_jobs%rowtype;
|
||||
begin
|
||||
if p_worker_id is null or btrim(p_worker_id) = '' then
|
||||
raise exception 'worker id is required';
|
||||
end if;
|
||||
|
||||
select * into v_job
|
||||
from public.ai_jobs
|
||||
where attempts < max_attempts
|
||||
and available_at <= now()
|
||||
and (
|
||||
status = 'queued'
|
||||
or (status = 'running' and lease_expires_at <= now())
|
||||
)
|
||||
order by created_at
|
||||
for update skip locked
|
||||
limit 1;
|
||||
|
||||
if not found then return; end if;
|
||||
|
||||
update public.ai_jobs
|
||||
set status = 'running',
|
||||
attempts = attempts + 1,
|
||||
claimed_by = p_worker_id,
|
||||
lease_expires_at = now() + interval '3 minutes',
|
||||
error = null,
|
||||
updated_at = now()
|
||||
where public.ai_jobs.id = v_job.id;
|
||||
|
||||
if v_job.job_type = 'resolve-round' then
|
||||
update public.rounds set status = 'resolving', error = null where public.rounds.id = v_job.entity_id;
|
||||
elsif v_job.job_type = 'generate-world' then
|
||||
update public.coauthor_sessions set status = 'generating', updated_at = now() where public.coauthor_sessions.id = v_job.entity_id;
|
||||
end if;
|
||||
|
||||
return query select v_job.id, v_job.job_type, v_job.entity_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.claim_ai_job_by_id(
|
||||
p_job_id uuid,
|
||||
p_worker_id text
|
||||
) returns table(id uuid, job_type text, entity_id uuid)
|
||||
language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_job public.ai_jobs%rowtype;
|
||||
begin
|
||||
if p_job_id is null then
|
||||
raise exception 'job id is required';
|
||||
end if;
|
||||
if p_worker_id is null or btrim(p_worker_id) = '' then
|
||||
raise exception 'worker id is required';
|
||||
end if;
|
||||
|
||||
select * into v_job
|
||||
from public.ai_jobs
|
||||
where public.ai_jobs.id = p_job_id
|
||||
and attempts < max_attempts
|
||||
and available_at <= now()
|
||||
and (
|
||||
status = 'queued'
|
||||
or (status = 'running' and lease_expires_at <= now())
|
||||
)
|
||||
for update;
|
||||
|
||||
if not found then return; end if;
|
||||
|
||||
update public.ai_jobs
|
||||
set status = 'running',
|
||||
attempts = attempts + 1,
|
||||
claimed_by = p_worker_id,
|
||||
lease_expires_at = now() + interval '3 minutes',
|
||||
error = null,
|
||||
updated_at = now()
|
||||
where public.ai_jobs.id = v_job.id;
|
||||
|
||||
if v_job.job_type = 'resolve-round' then
|
||||
update public.rounds set status = 'resolving', error = null where public.rounds.id = v_job.entity_id;
|
||||
elsif v_job.job_type = 'generate-world' then
|
||||
update public.coauthor_sessions set status = 'generating', updated_at = now() where public.coauthor_sessions.id = v_job.entity_id;
|
||||
end if;
|
||||
|
||||
return query select v_job.id, v_job.job_type, v_job.entity_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.complete_ai_job(
|
||||
p_job_id uuid,
|
||||
p_worker_id text
|
||||
) returns void language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
if p_worker_id is null or btrim(p_worker_id) = '' then
|
||||
raise exception 'worker id is required';
|
||||
end if;
|
||||
update public.ai_jobs
|
||||
set status = 'complete', claimed_by = null, lease_expires_at = null, updated_at = now()
|
||||
where id = p_job_id
|
||||
and status = 'running'
|
||||
and claimed_by = p_worker_id
|
||||
and lease_expires_at > now();
|
||||
if not found then raise exception 'job is not owned by this worker'; end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.retry_ai_job(
|
||||
p_job_id uuid,
|
||||
p_worker_id text,
|
||||
p_error text
|
||||
) returns void language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_job public.ai_jobs%rowtype;
|
||||
v_retry boolean;
|
||||
begin
|
||||
if p_worker_id is null or btrim(p_worker_id) = '' then
|
||||
raise exception 'worker id is required';
|
||||
end if;
|
||||
select * into v_job from public.ai_jobs where id = p_job_id for update;
|
||||
if not found or v_job.status <> 'running' or v_job.claimed_by <> p_worker_id then
|
||||
raise exception 'job is not owned by this worker';
|
||||
end if;
|
||||
if v_job.lease_expires_at <= now() then
|
||||
raise exception 'job lease has expired';
|
||||
end if;
|
||||
v_retry := v_job.attempts < v_job.max_attempts;
|
||||
update public.ai_jobs
|
||||
set status = case when v_retry then 'queued' else 'failed' end,
|
||||
claimed_by = null,
|
||||
lease_expires_at = null,
|
||||
available_at = case when v_retry then now() + make_interval(secs => 5 * power(2, v_job.attempts - 1)::integer) else available_at end,
|
||||
error = left(p_error, 2000),
|
||||
updated_at = now()
|
||||
where id = p_job_id;
|
||||
|
||||
if v_job.job_type = 'resolve-round' then
|
||||
update public.rounds
|
||||
set status = case when v_retry then 'queued'::public.round_status else 'failed'::public.round_status end,
|
||||
error = left(p_error, 2000)
|
||||
where id = v_job.entity_id and status <> 'resolved';
|
||||
elsif v_job.job_type = 'generate-world' then
|
||||
update public.coauthor_sessions
|
||||
set status = case when v_retry then 'generating' else 'failed' end, updated_at = now()
|
||||
where id = v_job.entity_id and status <> 'ready';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.renew_ai_job_lease(
|
||||
p_job_id uuid,
|
||||
p_worker_id text
|
||||
) returns timestamptz language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_lease_expires_at timestamptz;
|
||||
begin
|
||||
if p_worker_id is null or btrim(p_worker_id) = '' then
|
||||
raise exception 'worker id is required';
|
||||
end if;
|
||||
update public.ai_jobs
|
||||
set lease_expires_at = now() + interval '3 minutes', updated_at = now()
|
||||
where id = p_job_id
|
||||
and status = 'running'
|
||||
and claimed_by = p_worker_id
|
||||
and lease_expires_at > now()
|
||||
returning lease_expires_at into v_lease_expires_at;
|
||||
if not found then raise exception 'job is not owned by this worker or lease expired'; end if;
|
||||
return v_lease_expires_at;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.claim_ai_job(text) from public, anon, authenticated;
|
||||
revoke all on function public.claim_ai_job_by_id(uuid, text) from public, anon, authenticated;
|
||||
revoke all on function public.complete_ai_job(uuid, text) from public, anon, authenticated;
|
||||
revoke all on function public.retry_ai_job(uuid, text, text) from public, anon, authenticated;
|
||||
revoke all on function public.renew_ai_job_lease(uuid, text) from public, anon, authenticated;
|
||||
grant execute on function public.claim_ai_job(text) to service_role;
|
||||
grant execute on function public.claim_ai_job_by_id(uuid, text) to service_role;
|
||||
grant execute on function public.complete_ai_job(uuid, text) to service_role;
|
||||
grant execute on function public.retry_ai_job(uuid, text, text) to service_role;
|
||||
grant execute on function public.renew_ai_job_lease(uuid, text) to service_role;
|
||||
|
||||
create or replace function public.commit_round_resolution(
|
||||
p_round_id uuid,
|
||||
p_narration text,
|
||||
p_next_prompt text,
|
||||
p_rolls jsonb,
|
||||
p_events jsonb,
|
||||
p_character_states jsonb,
|
||||
p_memory jsonb,
|
||||
p_idempotency_key text
|
||||
) returns void language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_round public.rounds%rowtype;
|
||||
v_job public.ai_jobs%rowtype;
|
||||
v_roll jsonb;
|
||||
v_event jsonb;
|
||||
v_state jsonb;
|
||||
v_character_id uuid;
|
||||
v_seen_character_ids uuid[] := '{}';
|
||||
v_row_count integer;
|
||||
begin
|
||||
if p_idempotency_key is null or btrim(p_idempotency_key) = '' then
|
||||
raise exception 'idempotency key is required';
|
||||
end if;
|
||||
if p_narration is null or btrim(p_narration) = '' or p_next_prompt is null or btrim(p_next_prompt) = '' then
|
||||
raise exception 'narration and next prompt are required';
|
||||
end if;
|
||||
if coalesce(jsonb_typeof(p_rolls), '') <> 'array'
|
||||
or coalesce(jsonb_typeof(p_events), '') <> 'array'
|
||||
or coalesce(jsonb_typeof(p_character_states), '') <> 'array' then
|
||||
raise exception 'rolls, events, and character states must be arrays';
|
||||
end if;
|
||||
|
||||
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 = 'resolved' then return; end if;
|
||||
if v_round.status not in ('queued', 'resolving') then
|
||||
raise exception 'round is not queued for resolution';
|
||||
end if;
|
||||
|
||||
select * into v_job
|
||||
from public.ai_jobs
|
||||
where idempotency_key = p_idempotency_key
|
||||
or id::text = p_idempotency_key
|
||||
for update;
|
||||
if not found then
|
||||
raise exception 'resolution job not found';
|
||||
end if;
|
||||
if v_job.job_type <> 'resolve-round' or v_job.entity_id <> p_round_id then
|
||||
raise exception 'idempotency key belongs to another job';
|
||||
end if;
|
||||
if v_job.status = 'complete' then return; end if;
|
||||
if v_job.status <> 'running' then
|
||||
raise exception 'resolution job must be claimed before commit';
|
||||
end if;
|
||||
update public.ai_jobs
|
||||
set status = 'running',
|
||||
error = null,
|
||||
updated_at = now()
|
||||
where id = v_job.id;
|
||||
update public.rounds set status = 'resolving', error = null where id = p_round_id;
|
||||
|
||||
for v_roll in select * from jsonb_array_elements(p_rolls) loop
|
||||
if not exists (
|
||||
select 1 from public.characters
|
||||
where id = (v_roll->>'actorId')::uuid and campaign_id = v_round.campaign_id
|
||||
) or (
|
||||
nullif(v_roll->>'targetId', '') is not null
|
||||
and not exists (
|
||||
select 1 from public.characters
|
||||
where id = nullif(v_roll->>'targetId','')::uuid and campaign_id = v_round.campaign_id
|
||||
)
|
||||
) then
|
||||
raise exception 'roll actor or target is outside the round campaign';
|
||||
end if;
|
||||
insert into public.dice_rolls(id, round_id, actor_id, target_id, check_kind, formula, rolls, kept, modifier, total, difficulty, success, created_at)
|
||||
values ((v_roll->>'id')::uuid, p_round_id, (v_roll->>'actorId')::uuid, nullif(v_roll->>'targetId','')::uuid,
|
||||
v_roll->>'checkKind', v_roll->>'formula', array(select jsonb_array_elements_text(v_roll->'rolls')::integer),
|
||||
array(select jsonb_array_elements_text(v_roll->'kept')::integer), (v_roll->>'modifier')::integer,
|
||||
(v_roll->>'total')::integer, nullif(v_roll->>'difficulty','')::integer, (v_roll->>'success')::boolean,
|
||||
(v_roll->>'createdAt')::timestamptz);
|
||||
end loop;
|
||||
|
||||
for v_event in select * from jsonb_array_elements(p_events) loop
|
||||
if coalesce(btrim(v_event->>'type'), '') = '' then
|
||||
raise exception 'event type is required';
|
||||
end if;
|
||||
insert into public.game_events(campaign_id, round_id, event_type, payload)
|
||||
values (v_round.campaign_id, p_round_id, v_event->>'type', v_event);
|
||||
end loop;
|
||||
|
||||
for v_state in select * from jsonb_array_elements(p_character_states) loop
|
||||
v_character_id := (v_state->>'id')::uuid;
|
||||
if v_character_id = any(v_seen_character_ids) then
|
||||
raise exception 'duplicate character state for %', v_character_id;
|
||||
end if;
|
||||
v_seen_character_ids := array_append(v_seen_character_ids, v_character_id);
|
||||
if coalesce(jsonb_typeof(v_state->'inventory'), '') <> 'array'
|
||||
or coalesce(jsonb_typeof(v_state->'statuses'), '') <> 'array' then
|
||||
raise exception 'inventory and statuses must be arrays';
|
||||
end if;
|
||||
update public.characters
|
||||
set hp = (v_state->>'hp')::integer,
|
||||
inventory = v_state->'inventory',
|
||||
statuses = v_state->'statuses'
|
||||
where id = v_character_id and campaign_id = v_round.campaign_id;
|
||||
get diagnostics v_row_count = row_count;
|
||||
if v_row_count <> 1 then
|
||||
raise exception 'character % is outside the round campaign', v_character_id;
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
if p_memory is not null and p_memory <> 'null'::jsonb then
|
||||
if coalesce(jsonb_typeof(p_memory), '') <> 'object' then raise exception 'memory must be an object'; end if;
|
||||
insert into public.memories(campaign_id, round_id, summary, importance, tags, entity_ids)
|
||||
values (v_round.campaign_id, p_round_id, p_memory->>'summary', (p_memory->>'importance')::integer,
|
||||
array(select jsonb_array_elements_text(coalesce(p_memory->'tags', '[]'::jsonb))),
|
||||
array(select jsonb_array_elements_text(coalesce(p_memory->'entityIds', '[]'::jsonb))::uuid));
|
||||
end if;
|
||||
|
||||
update public.rounds set status = 'resolved', narration = p_narration, next_prompt = p_next_prompt, resolved_at = now() where id = p_round_id;
|
||||
update public.campaigns set current_scene = p_narration, next_prompt = p_next_prompt, updated_at = now() where id = v_round.campaign_id;
|
||||
update public.ai_jobs
|
||||
set status = 'complete', claimed_by = null, lease_expires_at = null, updated_at = now()
|
||||
where id = v_job.id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.commit_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text) from public, anon, authenticated;
|
||||
grant execute on function public.commit_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text) to service_role;
|
||||
|
||||
create or replace function public.commit_claimed_round_resolution(
|
||||
p_round_id uuid,
|
||||
p_narration text,
|
||||
p_next_prompt text,
|
||||
p_rolls jsonb,
|
||||
p_events jsonb,
|
||||
p_character_states jsonb,
|
||||
p_memory jsonb,
|
||||
p_idempotency_key text,
|
||||
p_worker_id text
|
||||
) returns void language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
if p_worker_id is null or btrim(p_worker_id) = '' then
|
||||
raise exception 'worker id is required';
|
||||
end if;
|
||||
if not exists (
|
||||
select 1 from public.ai_jobs
|
||||
where idempotency_key = p_idempotency_key
|
||||
and job_type = 'resolve-round'
|
||||
and entity_id = p_round_id
|
||||
and status = 'running'
|
||||
and claimed_by = p_worker_id
|
||||
and lease_expires_at > now()
|
||||
) then
|
||||
raise exception 'resolution job is not owned by this worker';
|
||||
end if;
|
||||
perform public.commit_round_resolution(p_round_id, p_narration, p_next_prompt, p_rolls, p_events, p_character_states, p_memory, p_idempotency_key);
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.commit_claimed_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) from public, anon, authenticated;
|
||||
grant execute on function public.commit_claimed_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) to service_role;
|
||||
486
supabase/migrations/0002_supabase_ai_queue.sql
Normal file
486
supabase/migrations/0002_supabase_ai_queue.sql
Normal file
@@ -0,0 +1,486 @@
|
||||
-- Adds the Redis-free Supabase outbox worker contract to databases created
|
||||
-- before queue leases were introduced. Safe to apply after the current 0001.
|
||||
|
||||
alter table public.ai_jobs add column if not exists claimed_by text;
|
||||
alter table public.ai_jobs add column if not exists lease_expires_at timestamptz;
|
||||
alter table public.ai_jobs add column if not exists available_at timestamptz not null default now();
|
||||
alter table public.ai_jobs add column if not exists max_attempts integer not null default 3;
|
||||
|
||||
-- A worker from the old implementation cannot own a database lease. Return
|
||||
-- such unfinished jobs to the queue before enforcing lease consistency.
|
||||
update public.ai_jobs
|
||||
set status = 'queued',
|
||||
claimed_by = null,
|
||||
lease_expires_at = null,
|
||||
available_at = now(),
|
||||
updated_at = now()
|
||||
where status = 'running'
|
||||
and (claimed_by is null or lease_expires_at is null);
|
||||
|
||||
update public.ai_jobs set available_at = now() where available_at is null;
|
||||
update public.ai_jobs set max_attempts = 3 where max_attempts is null;
|
||||
alter table public.ai_jobs alter column available_at set default now();
|
||||
alter table public.ai_jobs alter column available_at set not null;
|
||||
alter table public.ai_jobs alter column max_attempts set default 3;
|
||||
alter table public.ai_jobs alter column max_attempts set not null;
|
||||
alter table public.ai_jobs enable row level security;
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conrelid = 'public.ai_jobs'::regclass
|
||||
and conname = 'ai_jobs_max_attempts_check'
|
||||
) then
|
||||
alter table public.ai_jobs
|
||||
add constraint ai_jobs_max_attempts_check check (max_attempts between 1 and 10);
|
||||
end if;
|
||||
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conrelid = 'public.ai_jobs'::regclass
|
||||
and conname = 'ai_jobs_lease_consistency_check'
|
||||
) then
|
||||
alter table public.ai_jobs
|
||||
add constraint ai_jobs_lease_consistency_check check (
|
||||
(status = 'running' and claimed_by is not null and lease_expires_at is not null)
|
||||
or (status <> 'running' and claimed_by is null and lease_expires_at is null)
|
||||
);
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create index if not exists ai_jobs_poll_idx
|
||||
on public.ai_jobs(available_at, created_at) where status in ('queued', 'running');
|
||||
create index if not exists ai_jobs_expired_lease_idx
|
||||
on public.ai_jobs(lease_expires_at) where status = 'running';
|
||||
|
||||
create or replace function public.claim_ai_job(
|
||||
p_worker_id text
|
||||
) returns table(id uuid, job_type text, entity_id uuid)
|
||||
language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_job public.ai_jobs%rowtype;
|
||||
v_exhausted public.ai_jobs%rowtype;
|
||||
begin
|
||||
if p_worker_id is null or btrim(p_worker_id) = '' then
|
||||
raise exception 'worker id is required';
|
||||
end if;
|
||||
|
||||
for v_exhausted in
|
||||
select * from public.ai_jobs
|
||||
where (
|
||||
(status = 'running' and lease_expires_at <= now())
|
||||
or (status = 'queued' and available_at <= now())
|
||||
)
|
||||
and attempts >= max_attempts
|
||||
order by created_at
|
||||
for update skip locked
|
||||
limit 25
|
||||
loop
|
||||
update public.ai_jobs
|
||||
set status = 'failed', claimed_by = null, lease_expires_at = null,
|
||||
error = coalesce(error, 'Worker lease expired after the final attempt'), updated_at = now()
|
||||
where public.ai_jobs.id = v_exhausted.id;
|
||||
if v_exhausted.job_type = 'resolve-round' then
|
||||
update public.rounds
|
||||
set status = 'failed', error = 'Worker lease expired after the final attempt'
|
||||
where public.rounds.id = v_exhausted.entity_id and status <> 'resolved';
|
||||
elsif v_exhausted.job_type = 'generate-world' then
|
||||
update public.coauthor_sessions
|
||||
set status = 'failed', updated_at = now()
|
||||
where public.coauthor_sessions.id = v_exhausted.entity_id and status <> 'ready';
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
select * into v_job
|
||||
from public.ai_jobs
|
||||
where attempts < max_attempts
|
||||
and available_at <= now()
|
||||
and (
|
||||
status = 'queued'
|
||||
or (status = 'running' and lease_expires_at <= now())
|
||||
)
|
||||
order by created_at
|
||||
for update skip locked
|
||||
limit 1;
|
||||
|
||||
if not found then return; end if;
|
||||
|
||||
update public.ai_jobs
|
||||
set status = 'running',
|
||||
attempts = attempts + 1,
|
||||
claimed_by = p_worker_id,
|
||||
lease_expires_at = now() + interval '3 minutes',
|
||||
error = null,
|
||||
updated_at = now()
|
||||
where public.ai_jobs.id = v_job.id;
|
||||
|
||||
if v_job.job_type = 'resolve-round' then
|
||||
update public.rounds set status = 'resolving', error = null where public.rounds.id = v_job.entity_id;
|
||||
elsif v_job.job_type = 'generate-world' then
|
||||
update public.coauthor_sessions set status = 'generating', updated_at = now() where public.coauthor_sessions.id = v_job.entity_id;
|
||||
end if;
|
||||
|
||||
return query select v_job.id, v_job.job_type, v_job.entity_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.claim_ai_job_by_id(
|
||||
p_job_id uuid,
|
||||
p_worker_id text
|
||||
) returns table(id uuid, job_type text, entity_id uuid)
|
||||
language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_job public.ai_jobs%rowtype;
|
||||
begin
|
||||
if p_job_id is null then raise exception 'job id is required'; end if;
|
||||
if p_worker_id is null or btrim(p_worker_id) = '' then
|
||||
raise exception 'worker id is required';
|
||||
end if;
|
||||
|
||||
select * into v_job from public.ai_jobs where public.ai_jobs.id = p_job_id for update;
|
||||
|
||||
if not found then return; end if;
|
||||
|
||||
if (
|
||||
(v_job.status = 'running' and v_job.lease_expires_at <= now())
|
||||
or (v_job.status = 'queued' and v_job.available_at <= now())
|
||||
)
|
||||
and v_job.attempts >= v_job.max_attempts then
|
||||
update public.ai_jobs
|
||||
set status = 'failed', claimed_by = null, lease_expires_at = null,
|
||||
error = coalesce(error, 'Worker lease expired after the final attempt'), updated_at = now()
|
||||
where public.ai_jobs.id = v_job.id;
|
||||
if v_job.job_type = 'resolve-round' then
|
||||
update public.rounds
|
||||
set status = 'failed', error = 'Worker lease expired after the final attempt'
|
||||
where public.rounds.id = v_job.entity_id and status <> 'resolved';
|
||||
elsif v_job.job_type = 'generate-world' then
|
||||
update public.coauthor_sessions
|
||||
set status = 'failed', updated_at = now()
|
||||
where public.coauthor_sessions.id = v_job.entity_id and status <> 'ready';
|
||||
end if;
|
||||
return;
|
||||
end if;
|
||||
|
||||
if v_job.attempts >= v_job.max_attempts
|
||||
or v_job.available_at > now()
|
||||
or not (
|
||||
v_job.status = 'queued'
|
||||
or (v_job.status = 'running' and v_job.lease_expires_at <= now())
|
||||
) then
|
||||
return;
|
||||
end if;
|
||||
|
||||
update public.ai_jobs
|
||||
set status = 'running',
|
||||
attempts = attempts + 1,
|
||||
claimed_by = p_worker_id,
|
||||
lease_expires_at = now() + interval '3 minutes',
|
||||
error = null,
|
||||
updated_at = now()
|
||||
where public.ai_jobs.id = v_job.id;
|
||||
|
||||
if v_job.job_type = 'resolve-round' then
|
||||
update public.rounds set status = 'resolving', error = null where public.rounds.id = v_job.entity_id;
|
||||
elsif v_job.job_type = 'generate-world' then
|
||||
update public.coauthor_sessions set status = 'generating', updated_at = now() where public.coauthor_sessions.id = v_job.entity_id;
|
||||
end if;
|
||||
|
||||
return query select v_job.id, v_job.job_type, v_job.entity_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.complete_ai_job(
|
||||
p_job_id uuid,
|
||||
p_worker_id text
|
||||
) returns void language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
if p_worker_id is null or btrim(p_worker_id) = '' then
|
||||
raise exception 'worker id is required';
|
||||
end if;
|
||||
update public.ai_jobs
|
||||
set status = 'complete', claimed_by = null, lease_expires_at = null, updated_at = now()
|
||||
where id = p_job_id
|
||||
and status = 'running'
|
||||
and claimed_by = p_worker_id
|
||||
and lease_expires_at > now();
|
||||
if not found then raise exception 'job is not owned by this worker'; end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.retry_ai_job(
|
||||
p_job_id uuid,
|
||||
p_worker_id text,
|
||||
p_error text
|
||||
) returns void language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_job public.ai_jobs%rowtype;
|
||||
v_retry boolean;
|
||||
begin
|
||||
if p_worker_id is null or btrim(p_worker_id) = '' then
|
||||
raise exception 'worker id is required';
|
||||
end if;
|
||||
select * into v_job from public.ai_jobs where id = p_job_id for update;
|
||||
if not found or v_job.status <> 'running' or v_job.claimed_by <> p_worker_id then
|
||||
raise exception 'job is not owned by this worker';
|
||||
end if;
|
||||
if v_job.lease_expires_at <= now() then
|
||||
raise exception 'job lease has expired';
|
||||
end if;
|
||||
|
||||
v_retry := v_job.attempts < v_job.max_attempts;
|
||||
update public.ai_jobs
|
||||
set status = case when v_retry then 'queued' else 'failed' end,
|
||||
claimed_by = null,
|
||||
lease_expires_at = null,
|
||||
available_at = case
|
||||
when v_retry then now() + make_interval(secs => 5 * power(2, v_job.attempts - 1)::integer)
|
||||
else available_at
|
||||
end,
|
||||
error = left(p_error, 2000),
|
||||
updated_at = now()
|
||||
where id = p_job_id;
|
||||
|
||||
if v_job.job_type = 'resolve-round' then
|
||||
update public.rounds
|
||||
set status = case
|
||||
when v_retry then 'queued'::public.round_status
|
||||
else 'failed'::public.round_status
|
||||
end,
|
||||
error = left(p_error, 2000)
|
||||
where id = v_job.entity_id and status <> 'resolved';
|
||||
elsif v_job.job_type = 'generate-world' then
|
||||
update public.coauthor_sessions
|
||||
set status = case when v_retry then 'generating' else 'failed' end,
|
||||
updated_at = now()
|
||||
where id = v_job.entity_id and status <> 'ready';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.renew_ai_job_lease(
|
||||
p_job_id uuid,
|
||||
p_worker_id text
|
||||
) returns timestamptz language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_lease_expires_at timestamptz;
|
||||
begin
|
||||
if p_worker_id is null or btrim(p_worker_id) = '' then
|
||||
raise exception 'worker id is required';
|
||||
end if;
|
||||
update public.ai_jobs
|
||||
set lease_expires_at = now() + interval '3 minutes', updated_at = now()
|
||||
where id = p_job_id
|
||||
and status = 'running'
|
||||
and claimed_by = p_worker_id
|
||||
and lease_expires_at > now()
|
||||
returning lease_expires_at into v_lease_expires_at;
|
||||
if not found then raise exception 'job is not owned by this worker or lease expired'; end if;
|
||||
return v_lease_expires_at;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- Replace the pre-lease commit function as well: old databases completed a
|
||||
-- job without clearing its lease, which conflicts with the new invariant.
|
||||
create or replace function public.commit_round_resolution(
|
||||
p_round_id uuid,
|
||||
p_narration text,
|
||||
p_next_prompt text,
|
||||
p_rolls jsonb,
|
||||
p_events jsonb,
|
||||
p_character_states jsonb,
|
||||
p_memory jsonb,
|
||||
p_idempotency_key text
|
||||
) returns void language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_round public.rounds%rowtype;
|
||||
v_job public.ai_jobs%rowtype;
|
||||
v_roll jsonb;
|
||||
v_event jsonb;
|
||||
v_state jsonb;
|
||||
v_character_id uuid;
|
||||
v_seen_character_ids uuid[] := '{}';
|
||||
v_row_count integer;
|
||||
begin
|
||||
if p_idempotency_key is null or btrim(p_idempotency_key) = '' then
|
||||
raise exception 'idempotency key is required';
|
||||
end if;
|
||||
if p_narration is null or btrim(p_narration) = '' or p_next_prompt is null or btrim(p_next_prompt) = '' then
|
||||
raise exception 'narration and next prompt are required';
|
||||
end if;
|
||||
if coalesce(jsonb_typeof(p_rolls), '') <> 'array'
|
||||
or coalesce(jsonb_typeof(p_events), '') <> 'array'
|
||||
or coalesce(jsonb_typeof(p_character_states), '') <> 'array' then
|
||||
raise exception 'rolls, events, and character states must be arrays';
|
||||
end if;
|
||||
|
||||
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 = 'resolved' then return; end if;
|
||||
if v_round.status not in ('queued', 'resolving') then
|
||||
raise exception 'round is not queued for resolution';
|
||||
end if;
|
||||
|
||||
select * into v_job
|
||||
from public.ai_jobs
|
||||
where idempotency_key = p_idempotency_key
|
||||
or id::text = p_idempotency_key
|
||||
for update;
|
||||
if not found then raise exception 'resolution job not found'; end if;
|
||||
if v_job.job_type <> 'resolve-round' or v_job.entity_id <> p_round_id then
|
||||
raise exception 'idempotency key belongs to another job';
|
||||
end if;
|
||||
if v_job.status = 'complete' then return; end if;
|
||||
if v_job.status <> 'running' then
|
||||
raise exception 'resolution job must be claimed before commit';
|
||||
end if;
|
||||
|
||||
update public.ai_jobs set error = null, updated_at = now() where id = v_job.id;
|
||||
update public.rounds set status = 'resolving', error = null where id = p_round_id;
|
||||
|
||||
for v_roll in select * from jsonb_array_elements(p_rolls) loop
|
||||
if not exists (
|
||||
select 1 from public.characters
|
||||
where id = (v_roll->>'actorId')::uuid and campaign_id = v_round.campaign_id
|
||||
) or (
|
||||
nullif(v_roll->>'targetId', '') is not null
|
||||
and not exists (
|
||||
select 1 from public.characters
|
||||
where id = nullif(v_roll->>'targetId', '')::uuid and campaign_id = v_round.campaign_id
|
||||
)
|
||||
) then
|
||||
raise exception 'roll actor or target is outside the round campaign';
|
||||
end if;
|
||||
insert into public.dice_rolls(
|
||||
id, round_id, actor_id, target_id, check_kind, formula, rolls, kept,
|
||||
modifier, total, difficulty, success, created_at
|
||||
) values (
|
||||
(v_roll->>'id')::uuid, p_round_id, (v_roll->>'actorId')::uuid,
|
||||
nullif(v_roll->>'targetId', '')::uuid, v_roll->>'checkKind', v_roll->>'formula',
|
||||
array(select jsonb_array_elements_text(v_roll->'rolls')::integer),
|
||||
array(select jsonb_array_elements_text(v_roll->'kept')::integer),
|
||||
(v_roll->>'modifier')::integer, (v_roll->>'total')::integer,
|
||||
nullif(v_roll->>'difficulty', '')::integer, (v_roll->>'success')::boolean,
|
||||
(v_roll->>'createdAt')::timestamptz
|
||||
);
|
||||
end loop;
|
||||
|
||||
for v_event in select * from jsonb_array_elements(p_events) loop
|
||||
if coalesce(btrim(v_event->>'type'), '') = '' then
|
||||
raise exception 'event type is required';
|
||||
end if;
|
||||
insert into public.game_events(campaign_id, round_id, event_type, payload)
|
||||
values (v_round.campaign_id, p_round_id, v_event->>'type', v_event);
|
||||
end loop;
|
||||
|
||||
for v_state in select * from jsonb_array_elements(p_character_states) loop
|
||||
v_character_id := (v_state->>'id')::uuid;
|
||||
if v_character_id = any(v_seen_character_ids) then
|
||||
raise exception 'duplicate character state for %', v_character_id;
|
||||
end if;
|
||||
v_seen_character_ids := array_append(v_seen_character_ids, v_character_id);
|
||||
if coalesce(jsonb_typeof(v_state->'inventory'), '') <> 'array'
|
||||
or coalesce(jsonb_typeof(v_state->'statuses'), '') <> 'array' then
|
||||
raise exception 'inventory and statuses must be arrays';
|
||||
end if;
|
||||
update public.characters
|
||||
set hp = (v_state->>'hp')::integer,
|
||||
inventory = v_state->'inventory',
|
||||
statuses = v_state->'statuses'
|
||||
where id = v_character_id and campaign_id = v_round.campaign_id;
|
||||
get diagnostics v_row_count = row_count;
|
||||
if v_row_count <> 1 then
|
||||
raise exception 'character % is outside the round campaign', v_character_id;
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
if p_memory is not null and p_memory <> 'null'::jsonb then
|
||||
if coalesce(jsonb_typeof(p_memory), '') <> 'object' then
|
||||
raise exception 'memory must be an object';
|
||||
end if;
|
||||
insert into public.memories(campaign_id, round_id, summary, importance, tags, entity_ids)
|
||||
values (
|
||||
v_round.campaign_id,
|
||||
p_round_id,
|
||||
p_memory->>'summary',
|
||||
(p_memory->>'importance')::integer,
|
||||
array(select jsonb_array_elements_text(coalesce(p_memory->'tags', '[]'::jsonb))),
|
||||
array(select jsonb_array_elements_text(coalesce(p_memory->'entityIds', '[]'::jsonb))::uuid)
|
||||
);
|
||||
end if;
|
||||
|
||||
update public.rounds
|
||||
set status = 'resolved', narration = p_narration, next_prompt = p_next_prompt, resolved_at = now()
|
||||
where id = p_round_id;
|
||||
update public.campaigns
|
||||
set current_scene = p_narration, next_prompt = p_next_prompt, updated_at = now()
|
||||
where id = v_round.campaign_id;
|
||||
update public.ai_jobs
|
||||
set status = 'complete', claimed_by = null, lease_expires_at = null, updated_at = now()
|
||||
where id = v_job.id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.commit_claimed_round_resolution(
|
||||
p_round_id uuid,
|
||||
p_narration text,
|
||||
p_next_prompt text,
|
||||
p_rolls jsonb,
|
||||
p_events jsonb,
|
||||
p_character_states jsonb,
|
||||
p_memory jsonb,
|
||||
p_idempotency_key text,
|
||||
p_worker_id text
|
||||
) returns void language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_job public.ai_jobs%rowtype;
|
||||
begin
|
||||
if p_worker_id is null or btrim(p_worker_id) = '' then
|
||||
raise exception 'worker id is required';
|
||||
end if;
|
||||
select * into v_job
|
||||
from public.ai_jobs
|
||||
where (idempotency_key = p_idempotency_key or id::text = p_idempotency_key)
|
||||
and job_type = 'resolve-round'
|
||||
and entity_id = p_round_id
|
||||
for update;
|
||||
if not found
|
||||
or v_job.status <> 'running'
|
||||
or v_job.claimed_by <> p_worker_id
|
||||
or v_job.lease_expires_at <= now() then
|
||||
raise exception 'resolution job is not owned by this worker';
|
||||
end if;
|
||||
|
||||
perform public.commit_round_resolution(
|
||||
p_round_id,
|
||||
p_narration,
|
||||
p_next_prompt,
|
||||
p_rolls,
|
||||
p_events,
|
||||
p_character_states,
|
||||
p_memory,
|
||||
p_idempotency_key
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.claim_ai_job(text) from public, anon, authenticated;
|
||||
revoke all on function public.claim_ai_job_by_id(uuid, text) from public, anon, authenticated;
|
||||
revoke all on function public.complete_ai_job(uuid, text) from public, anon, authenticated;
|
||||
revoke all on function public.retry_ai_job(uuid, text, text) from public, anon, authenticated;
|
||||
revoke all on function public.renew_ai_job_lease(uuid, text) from public, anon, authenticated;
|
||||
revoke all on function public.commit_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text) from public, anon, authenticated;
|
||||
revoke all on function public.commit_claimed_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) from public, anon, authenticated;
|
||||
|
||||
grant execute on function public.claim_ai_job(text) to service_role;
|
||||
grant execute on function public.claim_ai_job_by_id(uuid, text) to service_role;
|
||||
grant execute on function public.complete_ai_job(uuid, text) to service_role;
|
||||
grant execute on function public.retry_ai_job(uuid, text, text) to service_role;
|
||||
grant execute on function public.renew_ai_job_lease(uuid, text) to service_role;
|
||||
grant execute on function public.commit_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text) to service_role;
|
||||
grant execute on function public.commit_claimed_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) to service_role;
|
||||
|
||||
-- Make newly-created RPCs visible to PostgREST immediately.
|
||||
notify pgrst, 'reload schema';
|
||||
2
supabase/seed.sql
Normal file
2
supabase/seed.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Replace with invited tester emails before applying in a shared environment.
|
||||
insert into public.allowlist(email) values ('founder@example.com') on conflict do nothing;
|
||||
Reference in New Issue
Block a user