Files
Dungeons-Ground/supabase/bootstrap.sql
pavel444-byte f7ca158fe4
Some checks failed
CI / validate (pull_request) Has been cancelled
CI / validate (push) Successful in 32m54s
fix(gameplay): start campaigns in playable state
Co-authored-by: multica-agent <github@multica.ai>
2026-08-21 19:41:50 +05:00

2585 lines
101 KiB
PL/PgSQL

-- GENERATED FILE. Rebuild with: pnpm db:bundle
-- Paste this entire file into a new Supabase SQL Editor query and click Run.
-- It is intended for a fresh Dungeons & Ground project. The explicit
-- transaction ensures a failure cannot leave a half-created schema.
begin;
do $$
begin
if to_regclass('auth.users') is null then
raise exception 'D&G bootstrap preflight failed: auth.users is missing';
end if;
if not exists (
select 1
from information_schema.columns
where table_schema = 'auth'
and table_name = 'users'
and column_name = 'is_anonymous'
) then
raise exception 'D&G bootstrap preflight failed: auth.users.is_anonymous is missing';
end if;
if to_regclass('public.profiles') is not null
or to_regclass('public.ai_jobs') is not null
or to_regtype('public.member_role') is not null
or to_regprocedure('public.claim_ai_job(text)') is not null then
raise exception 'D&G bootstrap preflight failed: a partial/existing D&G schema was found. Do not run the fresh bootstrap over it.';
end if;
end;
$$;
-- ============================================================================
-- 0001_alpha_schema.sql
-- ============================================================================
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'),
-- 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);
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'::regconfig, coalesce(summary, ''))) 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;
-- ============================================================================
-- 0002_supabase_ai_queue.sql
-- ============================================================================
-- 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';
-- ============================================================================
-- 0003_stage_two_multiplayer.sql
-- ============================================================================
-- 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';
-- ============================================================================
-- 0004_anonymous_alpha_access.sql
-- ============================================================================
-- 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';
-- ============================================================================
-- 0005_failed_round_retry.sql
-- ============================================================================
-- 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';
-- ============================================================================
-- 0006_retry_job_compatibility.sql
-- ============================================================================
-- 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';
-- ============================================================================
-- 0007_stage_three_four_completion.sql
-- ============================================================================
-- Complete the universe/character alpha slice: resumable coauthor questions and
-- an atomic, server-only character creation path.
alter table public.coauthor_sessions
add column if not exists current_question jsonb;
alter table public.coauthor_sessions
drop constraint if exists coauthor_sessions_current_question_object;
alter table public.coauthor_sessions
add constraint coauthor_sessions_current_question_object check (
current_question is null or jsonb_typeof(current_question) = 'object'
);
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,
current_question = 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_four_set_coauthor_question(
p_session_id uuid,
p_owner_id uuid,
p_question jsonb
) returns jsonb language plpgsql security definer set search_path = '' as $$
declare
v_result jsonb;
begin
if p_question is null
or jsonb_typeof(p_question) <> 'object'
or nullif(btrim(p_question->>'id'), '') is null
or char_length(btrim(p_question->>'label')) not between 5 and 300
or jsonb_typeof(p_question->'options') <> 'array'
or jsonb_array_length(p_question->'options') <> 3 then
raise exception 'invalid coauthor question';
end if;
update public.coauthor_sessions
set messages = messages || jsonb_build_array(jsonb_build_object(
'role', 'assistant', 'content', btrim(p_question->>'label')
)),
current_question = p_question,
updated_at = now()
where id = p_session_id and owner_id = p_owner_id and status = 'collecting'
returning current_question into v_result;
if not found then raise exception 'collecting coauthor session not found'; end if;
return v_result;
end;
$$;
create or replace function public.stage_four_create_character(
p_campaign_id uuid,
p_actor_id uuid,
p_controller text,
p_name text,
p_concept text,
p_abilities jsonb,
p_hp integer,
p_max_hp integer,
p_defense integer,
p_proficiency integer,
p_inventory jsonb,
p_statuses jsonb,
p_persona jsonb
) returns uuid language plpgsql security definer set search_path = '' as $$
declare
v_character_id uuid;
v_user_id uuid;
begin
if p_controller not in ('human', 'ai') then raise exception 'invalid character controller'; end if;
if not exists (
select 1 from public.campaign_members
where campaign_id = p_campaign_id and user_id = p_actor_id and active
) then raise exception 'active campaign membership is required'; end if;
if p_controller = 'ai' then
if not exists (
select 1 from public.campaigns where id = p_campaign_id and owner_id = p_actor_id
) then raise exception 'only the campaign owner can add AI heroes'; end if;
v_user_id := null;
else
v_user_id := p_actor_id;
perform pg_catalog.pg_advisory_xact_lock(
pg_catalog.hashtextextended(p_campaign_id::text || ':' || p_actor_id::text, 0)
);
if exists (
select 1 from public.characters
where campaign_id = p_campaign_id and user_id = p_actor_id
) then raise exception 'this player already has a character'; end if;
end if;
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
) values (
p_campaign_id, v_user_id, btrim(p_name), btrim(p_concept),
p_controller::public.character_controller, p_abilities, p_hp, p_max_hp,
p_defense, p_proficiency, p_inventory, p_statuses, p_persona
) returning id into v_character_id;
return v_character_id;
end;
$$;
create or replace function public.dng_schema_version()
returns integer language sql stable security definer set search_path = '' as $$
select 7;
$$;
revoke all on function public.stage_four_set_coauthor_question(uuid, uuid, jsonb) from public, anon, authenticated;
revoke all on function public.stage_four_create_character(uuid, uuid, text, text, text, jsonb, integer, integer, integer, integer, jsonb, jsonb, jsonb) from public, anon, authenticated;
revoke all on function public.dng_schema_version() from public, anon, authenticated;
grant execute on function public.stage_four_set_coauthor_question(uuid, uuid, jsonb) to service_role;
grant execute on function public.stage_four_create_character(uuid, uuid, text, text, text, jsonb, integer, integer, integer, integer, jsonb, jsonb, jsonb) to service_role;
grant execute on function public.dng_schema_version() to service_role;
notify pgrst, 'reload schema';
-- ============================================================================
-- 0008_required_email_accounts.sql
-- ============================================================================
-- D&G now uses recoverable email/password accounts. Keep the legacy function
-- names so existing auth.users triggers are upgraded in place.
create or replace function public.create_profile_for_allowlisted_user()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
declare
v_display_name text;
begin
if coalesce(new.is_anonymous, false) or nullif(btrim(new.email), '') is null then
raise exception using
errcode = '42501',
message = 'A Dungeons & Ground account requires an email address.';
end if;
v_display_name := coalesce(
nullif(btrim(new.raw_user_meta_data ->> 'display_name'), ''),
split_part(new.email, '@', 1),
'Adventurer'
);
insert into public.profiles(id, display_name)
values (new.id, left(v_display_name, 80))
on conflict (id) do update
set display_name = excluded.display_name;
return new;
end;
$$;
revoke all on function public.create_profile_for_allowlisted_user() from public, anon, authenticated;
create or replace function public.enforce_alpha_email_allowlist()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
if coalesce(new.is_anonymous, false) or nullif(btrim(new.email), '') is null then
raise exception using
errcode = '42501',
message = 'A Dungeons & Ground account requires an email address.';
end if;
return new;
end;
$$;
revoke all on function public.enforce_alpha_email_allowlist() from public, anon, authenticated;
-- Installations may contain confirmed email users created before the D&G
-- schema. Give every non-anonymous email account a profile without touching
-- existing campaign ownership or display names.
insert into public.profiles(id, display_name)
select
auth_user.id,
left(coalesce(
nullif(btrim(auth_user.raw_user_meta_data ->> 'display_name'), ''),
split_part(auth_user.email, '@', 1),
'Adventurer'
), 80)
from auth.users auth_user
where not coalesce(auth_user.is_anonymous, false)
and nullif(btrim(auth_user.email), '') is not null
on conflict (id) do nothing;
create or replace function public.dng_schema_version()
returns integer language sql stable security definer set search_path = '' as $$
select 8;
$$;
revoke all on function public.dng_schema_version() from public, anon, authenticated;
grant execute on function public.dng_schema_version() to service_role;
notify pgrst, 'reload schema';
-- ============================================================================
-- 0009_profile_management.sql
-- ============================================================================
alter table public.profiles
add column description text not null default '' check (char_length(description) <= 500),
add column avatar_path text check (avatar_path is null or avatar_path = id::text || '/avatar'),
add column updated_at timestamptz not null default now();
-- Avatars are public profile media, but only the owning authenticated user may
-- create, replace, or remove the one deterministic object in their folder.
insert into storage.buckets(id, name, public, file_size_limit, allowed_mime_types)
values (
'profile-avatars',
'profile-avatars',
true,
2097152,
array['image/jpeg', 'image/png', 'image/webp']
)
on conflict (id) do update
set public = excluded.public,
file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types;
create policy dng_profile_avatar_insert
on storage.objects for insert to authenticated
with check (
bucket_id = 'profile-avatars'
and name = (select auth.uid())::text || '/avatar'
);
create policy dng_profile_avatar_select_own
on storage.objects for select to authenticated
using (
bucket_id = 'profile-avatars'
and name = (select auth.uid())::text || '/avatar'
);
create policy dng_profile_avatar_update
on storage.objects for update to authenticated
using (
bucket_id = 'profile-avatars'
and name = (select auth.uid())::text || '/avatar'
)
with check (
bucket_id = 'profile-avatars'
and name = (select auth.uid())::text || '/avatar'
);
create policy dng_profile_avatar_delete
on storage.objects for delete to authenticated
using (
bucket_id = 'profile-avatars'
and name = (select auth.uid())::text || '/avatar'
);
create or replace function public.dng_schema_version()
returns integer language sql stable security definer set search_path = '' as $$
select 9;
$$;
revoke all on function public.dng_schema_version() from public, anon, authenticated;
grant execute on function public.dng_schema_version() to service_role;
notify pgrst, 'reload schema';
-- ============================================================================
-- 0010_playable_starter_parties.sql
-- ============================================================================
-- Make a newly launched campaign playable immediately: the owner receives a
-- human-controlled starter and the party receives one persistent AI companion.
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;
v_owner_name text;
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;
select nullif(btrim(display_name), '') into v_owner_name
from public.profiles where id = p_owner_id;
v_owner_name := left(coalesce(v_owner_name, 'Wayfinder'), 80);
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.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
) values (
v_campaign_id,
p_owner_id,
v_owner_name,
left('An adaptable protagonist ready to confront the opening mystery of ' || v_world.title || '.', 600),
'human'::public.character_controller,
'{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb,
12,
12,
12,
2,
'["Field kit","Personal keepsake"]'::jsonb,
'[]'::jsonb,
jsonb_build_object(
'voice', 'Defined by the player.',
'motivation', 'Discover what the opening scene is hiding.',
'flaw', 'Still learning what this world demands.',
'bond', 'Protect the party through the first danger.'
)
);
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
) values (
v_campaign_id,
null,
'Echo',
left('A persistent AI companion shaped by the ' || v_world.genre || ' world of ' || v_world.title || ', ready to support the player without taking over their choices.', 600),
'ai'::public.character_controller,
'{"str":9,"dex":13,"con":11,"int":13,"wis":12,"cha":10}'::jsonb,
11,
11,
13,
2,
'["Survey kit","Emergency supplies"]'::jsonb,
'[]'::jsonb,
jsonb_build_object(
'voice', 'Observant, concise, and quietly curious.',
'motivation', 'Help the party understand this unfamiliar world.',
'flaw', 'Sometimes values patterns more than instinct.',
'bond', 'Stays beside the player when the path turns dangerous.'
)
);
insert into public.rounds(campaign_id, number) values (v_campaign_id, 1);
return v_campaign_id;
end;
$$;
-- Members who have not created a character cannot submit an intent and must not
-- hold the round open. Only active members with a human-controlled character
-- participate in the readiness barrier.
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
join public.characters character
on character.campaign_id = member.campaign_id
and character.user_id = member.user_id
and character.controller = 'human'::public.character_controller
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;
$$;
-- Repair campaigns created before starter parties were automatic.
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
)
select
campaign.id,
campaign.owner_id,
left(coalesce(nullif(btrim(profile.display_name), ''), 'Wayfinder'), 80),
left('An adaptable protagonist ready to confront the opening mystery of ' || world.title || '.', 600),
'human'::public.character_controller,
'{"str":10,"dex":12,"con":12,"int":11,"wis":13,"cha":10}'::jsonb,
12,
12,
12,
2,
'["Field kit","Personal keepsake"]'::jsonb,
'[]'::jsonb,
jsonb_build_object(
'voice', 'Defined by the player.',
'motivation', 'Discover what the opening scene is hiding.',
'flaw', 'Still learning what this world demands.',
'bond', 'Protect the party through the first danger.'
)
from public.campaigns campaign
join public.worlds world on world.id = campaign.world_id
left join public.profiles profile on profile.id = campaign.owner_id
where campaign.status = 'active'
and not exists (
select 1 from public.characters character
where character.campaign_id = campaign.id
and character.user_id = campaign.owner_id
);
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
)
select
campaign.id,
null,
'Echo',
left('A persistent AI companion shaped by the ' || world.genre || ' world of ' || world.title || ', ready to support the player without taking over their choices.', 600),
'ai'::public.character_controller,
'{"str":9,"dex":13,"con":11,"int":13,"wis":12,"cha":10}'::jsonb,
11,
11,
13,
2,
'["Survey kit","Emergency supplies"]'::jsonb,
'[]'::jsonb,
jsonb_build_object(
'voice', 'Observant, concise, and quietly curious.',
'motivation', 'Help the party understand this unfamiliar world.',
'flaw', 'Sometimes values patterns more than instinct.',
'bond', 'Stays beside the player when the path turns dangerous.'
)
from public.campaigns campaign
join public.worlds world on world.id = campaign.world_id
where campaign.status = 'active'
and not exists (
select 1 from public.characters character
where character.campaign_id = campaign.id
and character.controller = 'ai'::public.character_controller
);
create or replace function public.dng_schema_version()
returns integer language sql stable security definer set search_path = '' as $$
select 10;
$$;
revoke all on function public.dng_schema_version() from public, anon, authenticated;
grant execute on function public.dng_schema_version() to service_role;
notify pgrst, 'reload schema';
do $$
begin
if to_regclass('public.profiles') is null then
raise exception 'D&G bootstrap verification failed: public.profiles is missing';
end if;
if to_regclass('public.ai_jobs') is null then
raise exception 'D&G bootstrap verification failed: public.ai_jobs is missing';
end if;
if to_regprocedure('public.claim_ai_job(text)') is null then
raise exception 'D&G bootstrap verification failed: public.claim_ai_job(text) is missing';
end if;
if to_regprocedure('public.stage_two_create_campaign(uuid,uuid,text)') is null then
raise exception 'D&G bootstrap verification failed: stage-two RPCs are missing';
end if;
if to_regprocedure('public.stage_two_retry_failed_round(uuid,uuid)') is null then
raise exception 'D&G bootstrap verification failed: failed-round recovery RPC is missing';
end if;
if to_regprocedure('public.dng_schema_version()') is null then
raise exception 'D&G bootstrap verification failed: schema version RPC is missing';
end if;
if to_regprocedure('public.stage_four_create_character(uuid,uuid,text,text,text,jsonb,integer,integer,integer,integer,jsonb,jsonb,jsonb)') is null then
raise exception 'D&G bootstrap verification failed: character creation RPC is missing';
end if;
if public.dng_schema_version() <> 10 then
raise exception 'D&G bootstrap verification failed: unexpected schema version';
end if;
end;
$$;
notify pgrst, 'reload schema';
commit;
select
'Dungeons & Ground database is ready' as result,
to_regclass('public.profiles') as profiles,
to_regclass('public.ai_jobs') as ai_jobs,
to_regprocedure('public.claim_ai_job(text)') as worker_rpc;