Files
Dungeons-Ground/supabase/bootstrap.sql
pavel444-byte 031d094867
Some checks failed
CI / validate (push) Has been cancelled
CI / validate (pull_request) Has been cancelled
feat(memory): implement stability stage
Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 15:04:18 +05:00

3726 lines
153 KiB
PL/PgSQL
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 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 only actionable members hold the round open.
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.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
);
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';
-- ============================================================================
-- 0011_universe_companions.sql
-- ============================================================================
-- Persist the AI companions created with each universe and use that party when
-- a campaign starts. This replaces the temporary hard-coded Echo companion.
alter table public.worlds
add column if not exists starter_companions jsonb not null default '[]'::jsonb;
alter table public.worlds
drop constraint if exists worlds_starter_companions_array;
alter table public.worlds
add constraint worlds_starter_companions_array check (
jsonb_typeof(starter_companions) = 'array'
and jsonb_array_length(starter_companions) <= 3
);
-- Ready drafts created before companions were part of WorldStarter inherit
-- three universe-specific people that the coauthor already generated.
update public.coauthor_sessions session
set generated_world = jsonb_set(
session.generated_world,
'{companions}',
coalesce((
select jsonb_agg(
jsonb_build_object(
'name', npc.value->>'name',
'concept', left(npc.value->>'summary', 600),
'abilities', case npc.position
when 1 then '{"str":10,"dex":14,"con":11,"int":13,"wis":12,"cha":10}'::jsonb
when 2 then '{"str":13,"dex":10,"con":14,"int":10,"wis":12,"cha":11}'::jsonb
else '{"str":9,"dex":12,"con":11,"int":14,"wis":13,"cha":12}'::jsonb
end,
'hp', 10 + npc.position,
'maxHp', 10 + npc.position,
'defense', 11 + npc.position,
'proficiency', 2,
'inventory', jsonb_build_array('Universe field kit', 'Travel supplies'),
'persona', jsonb_build_object(
'voice', left(npc.value->>'summary', 240),
'motivation', left('Pursue the goal behind: ' || (npc.value->>'summary'), 300),
'flaw', 'Their personal agenda can complicate the party''s plans.',
'bond', left('They belong to ' || (session.generated_world->>'title') || ' and choose to stand with the party.', 300)
)
) order by npc.position
)
from jsonb_array_elements(coalesce(session.generated_world->'npcs', '[]'::jsonb))
with ordinality as npc(value, position)
where npc.position <= 3
), '[]'::jsonb),
true
),
updated_at = now()
where session.generated_world is not null
and jsonb_typeof(session.generated_world->'npcs') = 'array'
and coalesce(jsonb_array_length(session.generated_world->'companions'), 0) = 0;
-- Confirmed universes do not retain the original generation JSON. Build their
-- starter companions from the AI-created NPC entities already stored for them.
update public.worlds world
set starter_companions = coalesce((
select jsonb_agg(
jsonb_build_object(
'name', npc.name,
'concept', left(npc.summary, 600),
'abilities', case npc.position
when 1 then '{"str":10,"dex":14,"con":11,"int":13,"wis":12,"cha":10}'::jsonb
when 2 then '{"str":13,"dex":10,"con":14,"int":10,"wis":12,"cha":11}'::jsonb
else '{"str":9,"dex":12,"con":11,"int":14,"wis":13,"cha":12}'::jsonb
end,
'hp', 10 + npc.position,
'maxHp', 10 + npc.position,
'defense', 11 + npc.position,
'proficiency', 2,
'inventory', jsonb_build_array('Universe field kit', 'Travel supplies'),
'persona', jsonb_build_object(
'voice', left(npc.summary, 240),
'motivation', left('Pursue the goal behind: ' || npc.summary, 300),
'flaw', 'Their personal agenda can complicate the party''s plans.',
'bond', left('They belong to ' || world.title || ' and choose to stand with the party.', 300)
)
) order by npc.position
)
from (
select entity.name, entity.summary,
(row_number() over (order by entity.created_at, entity.id))::integer as position
from public.world_entities entity
where entity.world_id = world.id and entity.kind = 'npc'
order by entity.created_at, entity.id
limit 3
) npc
), '[]'::jsonb)
where jsonb_array_length(world.starter_companions) = 0;
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', '') = ''
or jsonb_typeof(v_world->'companions') <> 'array'
or jsonb_array_length(v_world->'companions') <> 3 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, starter_companions, 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', v_world->'companions', '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;
v_owner_name text;
v_companion jsonb;
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;
if jsonb_typeof(v_world.starter_companions) <> 'array'
or jsonb_array_length(v_world.starter_companions) = 0 then
raise exception 'confirmed world has no AI companions';
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.'
)
);
for v_companion in select * from jsonb_array_elements(v_world.starter_companions) loop
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,
v_companion->>'name',
v_companion->>'concept',
'ai'::public.character_controller,
v_companion->'abilities',
(v_companion->>'hp')::integer,
(v_companion->>'maxHp')::integer,
(v_companion->>'defense')::integer,
(v_companion->>'proficiency')::integer,
v_companion->'inventory',
'[]'::jsonb,
v_companion->'persona'
);
end loop;
insert into public.rounds(campaign_id, number) values (v_campaign_id, 1);
return v_campaign_id;
end;
$$;
-- Remove only the exact temporary Echo inserted by schema v10, preserving any
-- independently authored character that happens to share the name.
delete from public.characters character
where character.user_id is null
and character.controller = 'ai'::public.character_controller
and character.name = 'Echo'
and character.inventory = '["Survey kit","Emergency supplies"]'::jsonb
and character.persona->>'voice' = 'Observant, concise, and quietly curious.'
and character.persona->>'motivation' = 'Help the party understand this unfamiliar world.'
and character.concept like 'A persistent AI companion shaped by the %';
-- Existing active campaigns receive the companion roster belonging to their
-- universe. Name matching keeps this migration idempotent around manual heroes.
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona
)
select
campaign.id,
null,
companion.value->>'name',
companion.value->>'concept',
'ai'::public.character_controller,
companion.value->'abilities',
(companion.value->>'hp')::integer,
(companion.value->>'maxHp')::integer,
(companion.value->>'defense')::integer,
(companion.value->>'proficiency')::integer,
companion.value->'inventory',
'[]'::jsonb,
companion.value->'persona'
from public.campaigns campaign
join public.worlds world on world.id = campaign.world_id
cross join lateral jsonb_array_elements(world.starter_companions) companion(value)
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
and lower(character.name) = lower(companion.value->>'name')
);
create or replace function public.dng_schema_version()
returns integer language sql stable security definer set search_path = '' as $$
select 11;
$$;
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';
-- ============================================================================
-- 0012_versioned_srd_rules.sql
-- ============================================================================
-- Introduce a versioned SRD 5.2.1 rules profile without rebuilding campaigns.
-- Existing narrative, rounds, HP, inventory and statuses remain untouched.
alter table public.campaigns
add column if not exists ruleset_version smallint;
insert into public.audit_entries(
campaign_id, actor_id, action, entity_type, entity_id, before_state, after_state
)
select
campaign.id,
campaign.owner_id,
'migrate_ruleset',
'campaign',
campaign.id,
jsonb_build_object('rulesetVersion', coalesce(campaign.ruleset_version, 1)),
jsonb_build_object(
'rulesetVersion', 2,
'ruleset', 'SRD 5.2.1 alpha',
'preserved', jsonb_build_array('rounds', 'history', 'hp', 'inventory', 'statuses')
)
from public.campaigns campaign
where coalesce(campaign.ruleset_version, 1) < 2;
update public.campaigns
set ruleset_version = 2
where ruleset_version is null or ruleset_version < 2;
alter table public.campaigns
alter column ruleset_version set default 2,
alter column ruleset_version set not null;
alter table public.campaigns
drop constraint if exists campaigns_ruleset_version_supported;
alter table public.campaigns
add constraint campaigns_ruleset_version_supported check (ruleset_version = 2);
alter table public.characters
add column if not exists rules_state jsonb;
-- Compatibility is deliberate: migrated heroes retain the old planner hint
-- only until their profile is explicitly edited. Newly-authored profiles use
-- server-owned skill/save lists and set this flag to false.
update public.characters character
set rules_state = jsonb_build_object(
'version', 2,
'level', case
when character.proficiency <= 2 then 1
when character.proficiency = 3 then 5
when character.proficiency = 4 then 9
when character.proficiency = 5 then 13
else 17
end,
'skillProficiencies', '[]'::jsonb,
'skillExpertise', '[]'::jsonb,
'savingThrowProficiencies', '[]'::jsonb,
'temporaryHp', 0,
'deathSaves', jsonb_build_object('successes', 0, 'failures', 0),
'exhaustion', 0,
'damageResistances', '[]'::jsonb,
'damageVulnerabilities', '[]'::jsonb,
'damageImmunities', '[]'::jsonb,
'resources', jsonb_build_object(
'hitDice', jsonb_build_object('current', 1, 'max', 1, 'recovery', 'longRest')
),
'legacyProficiencyFallback', true
)
where character.rules_state is null;
alter table public.characters
alter column rules_state set default '{
"version":2,
"level":1,
"skillProficiencies":[],
"skillExpertise":[],
"savingThrowProficiencies":[],
"temporaryHp":0,
"deathSaves":{"successes":0,"failures":0},
"exhaustion":0,
"damageResistances":[],
"damageVulnerabilities":[],
"damageImmunities":[],
"resources":{"hitDice":{"current":1,"max":1,"recovery":"longRest"}},
"legacyProficiencyFallback":true
}'::jsonb,
alter column rules_state set not null;
alter table public.characters
drop constraint if exists characters_rules_state_v2;
alter table public.characters
add constraint characters_rules_state_v2 check (
jsonb_typeof(rules_state) = 'object'
and rules_state->>'version' = '2'
and (rules_state->>'level')::integer between 1 and 20
and jsonb_typeof(rules_state->'skillProficiencies') = 'array'
and jsonb_typeof(rules_state->'skillExpertise') = 'array'
and jsonb_typeof(rules_state->'savingThrowProficiencies') = 'array'
and (rules_state->>'temporaryHp')::integer between 0 and 999
and jsonb_typeof(rules_state->'deathSaves') = 'object'
and (rules_state->'deathSaves'->>'successes')::integer between 0 and 3
and (rules_state->'deathSaves'->>'failures')::integer between 0 and 3
and (rules_state->>'exhaustion')::integer between 0 and 6
and jsonb_typeof(rules_state->'damageResistances') = 'array'
and jsonb_typeof(rules_state->'damageVulnerabilities') = 'array'
and jsonb_typeof(rules_state->'damageImmunities') = 'array'
and jsonb_typeof(rules_state->'resources') = 'object'
and jsonb_typeof(rules_state->'legacyProficiencyFallback') = 'boolean'
);
-- Older world drafts gain deterministic proficiency data so a newly-started
-- campaign is fully on rules v2 even when its universe predates this migration.
update public.worlds world
set starter_companions = coalesce((
select jsonb_agg(
companion.value || case companion.position
when 1 then '{"skillProficiencies":["acrobatics","perception","stealth","survival"],"skillExpertise":[],"savingThrowProficiencies":["dex","wis"]}'::jsonb
when 2 then '{"skillProficiencies":["athletics","intimidation","perception","survival"],"skillExpertise":[],"savingThrowProficiencies":["str","con"]}'::jsonb
else '{"skillProficiencies":["arcana","history","investigation","perception"],"skillExpertise":["investigation"],"savingThrowProficiencies":["int","wis"]}'::jsonb
end
order by companion.position
)
from jsonb_array_elements(world.starter_companions) with ordinality as companion(value, position)
), '[]'::jsonb)
where jsonb_typeof(world.starter_companions) = 'array';
update public.coauthor_sessions session
set generated_world = jsonb_set(
session.generated_world,
'{companions}',
coalesce((
select jsonb_agg(
companion.value || case companion.position
when 1 then '{"skillProficiencies":["acrobatics","perception","stealth","survival"],"skillExpertise":[],"savingThrowProficiencies":["dex","wis"]}'::jsonb
when 2 then '{"skillProficiencies":["athletics","intimidation","perception","survival"],"skillExpertise":[],"savingThrowProficiencies":["str","con"]}'::jsonb
else '{"skillProficiencies":["arcana","history","investigation","perception"],"skillExpertise":["investigation"],"savingThrowProficiencies":["int","wis"]}'::jsonb
end
order by companion.position
)
from jsonb_array_elements(session.generated_world->'companions') with ordinality as companion(value, position)
), '[]'::jsonb),
true
)
where session.generated_world is not null
and jsonb_typeof(session.generated_world->'companions') = 'array';
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;
v_companion jsonb;
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;
if jsonb_typeof(v_world.starter_companions) <> 'array'
or jsonb_array_length(v_world.starter_companions) = 0 then
raise exception 'confirmed world has no AI companions';
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, ruleset_version)
values (p_world_id, p_owner_id, btrim(p_title), coalesce(v_world.opening_scene, v_world.premise), 'What do you do?', 'active', 2)
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, rules_state
) 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.'
),
'{
"version":2,"level":1,
"skillProficiencies":["investigation","perception","persuasion","survival"],
"skillExpertise":[],"savingThrowProficiencies":["dex","wis"],
"temporaryHp":0,"deathSaves":{"successes":0,"failures":0},"exhaustion":0,
"damageResistances":[],"damageVulnerabilities":[],"damageImmunities":[],
"resources":{"hitDice":{"current":1,"max":1,"recovery":"longRest"}},
"legacyProficiencyFallback":false
}'::jsonb
);
for v_companion in select * from jsonb_array_elements(v_world.starter_companions) loop
insert into public.characters(
campaign_id, user_id, name, concept, controller, abilities, hp, max_hp,
defense, proficiency, inventory, statuses, persona, rules_state
) values (
v_campaign_id, null, v_companion->>'name', v_companion->>'concept',
'ai'::public.character_controller, v_companion->'abilities',
(v_companion->>'hp')::integer, (v_companion->>'maxHp')::integer,
(v_companion->>'defense')::integer, (v_companion->>'proficiency')::integer,
v_companion->'inventory', '[]'::jsonb, v_companion->'persona',
jsonb_build_object(
'version', 2, 'level', 1,
'skillProficiencies', coalesce(v_companion->'skillProficiencies', '[]'::jsonb),
'skillExpertise', coalesce(v_companion->'skillExpertise', '[]'::jsonb),
'savingThrowProficiencies', coalesce(v_companion->'savingThrowProficiencies', '[]'::jsonb),
'temporaryHp', 0, 'deathSaves', jsonb_build_object('successes', 0, 'failures', 0),
'exhaustion', 0, 'damageResistances', '[]'::jsonb,
'damageVulnerabilities', '[]'::jsonb, 'damageImmunities', '[]'::jsonb,
'resources', jsonb_build_object('hitDice', jsonb_build_object('current', 1, 'max', 1, 'recovery', 'longRest')),
'legacyProficiencyFallback', false
)
);
end loop;
insert into public.rounds(campaign_id, number) values (v_campaign_id, 1);
return v_campaign_id;
end;
$$;
create or replace function public.stage_five_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,
p_rules_state jsonb
) returns uuid language plpgsql security definer set search_path = '' as $$
declare
v_character_id uuid;
begin
if coalesce(jsonb_typeof(p_rules_state), '') <> 'object' or p_rules_state->>'version' <> '2' then
raise exception 'a version 2 rules profile is required';
end if;
v_character_id := public.stage_four_create_character(
p_campaign_id, p_actor_id, p_controller, p_name, p_concept, p_abilities,
p_hp, p_max_hp, p_defense, p_proficiency, p_inventory, p_statuses, p_persona
);
update public.characters set rules_state = p_rules_state where id = v_character_id;
return v_character_id;
end;
$$;
create or replace function public.apply_srd_character_rules(
p_round_id uuid,
p_character_states jsonb
) returns void language plpgsql security definer set search_path = '' as $$
declare
v_campaign_id uuid;
v_state jsonb;
v_character_id uuid;
v_seen uuid[] := '{}';
begin
select campaign_id into v_campaign_id from public.rounds where id = p_round_id;
if not found then raise exception 'round not found'; end if;
if coalesce(jsonb_typeof(p_character_states), '') <> 'array' then
raise exception 'character states must be an array';
end if;
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) then raise exception 'duplicate character state for %', v_character_id; end if;
v_seen := array_append(v_seen, v_character_id);
if coalesce(jsonb_typeof(v_state->'rulesState'), '') <> 'object'
or v_state->'rulesState'->>'version' <> '2' then
raise exception 'character % has an invalid rules profile', v_character_id;
end if;
update public.characters
set rules_state = v_state->'rulesState'
where id = v_character_id and campaign_id = v_campaign_id;
if not found then raise exception 'character % is outside the round campaign', v_character_id; end if;
end loop;
end;
$$;
create or replace function public.commit_srd_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 $$
begin
if exists (select 1 from public.rounds where id = p_round_id and status = 'resolved') then return; 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
);
perform public.apply_srd_character_rules(p_round_id, p_character_states);
end;
$$;
create or replace function public.commit_claimed_srd_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
perform public.commit_claimed_round_resolution(
p_round_id, p_narration, p_next_prompt, p_rolls, p_events,
p_character_states, p_memory, p_idempotency_key, p_worker_id
);
perform public.apply_srd_character_rules(p_round_id, p_character_states);
end;
$$;
create or replace function public.dng_schema_version()
returns integer language sql stable security definer set search_path = '' as $$
select 12;
$$;
revoke all on function public.stage_five_create_character(uuid, uuid, text, text, text, jsonb, integer, integer, integer, integer, jsonb, jsonb, jsonb, jsonb) from public, anon, authenticated;
revoke all on function public.apply_srd_character_rules(uuid, jsonb) from public, anon, authenticated;
revoke all on function public.commit_srd_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text) from public, anon, authenticated;
revoke all on function public.commit_claimed_srd_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) from public, anon, authenticated;
revoke all on function public.dng_schema_version() from public, anon, authenticated;
grant execute on function public.stage_five_create_character(uuid, uuid, text, text, text, jsonb, integer, integer, integer, integer, jsonb, jsonb, jsonb, jsonb) to service_role;
grant execute on function public.commit_srd_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text) to service_role;
grant execute on function public.commit_claimed_srd_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) to service_role;
grant execute on function public.dng_schema_version() to service_role;
notify pgrst, 'reload schema';
-- ============================================================================
-- 0013_memory_and_stability.sql
-- ============================================================================
-- Weeks 78: durable world projections, PostgreSQL context retrieval, quotas,
-- usage telemetry, atomic summaries, and owner-audited corrections.
alter table public.ai_usage add column if not exists usage_key text;
alter table public.ai_usage add column if not exists provider text not null default 'openrouter';
alter table public.ai_usage add column if not exists request_kind text not null default 'unknown';
update public.ai_usage set usage_key = id::text where usage_key is null;
alter table public.ai_usage alter column usage_key set not null;
create unique index if not exists ai_usage_usage_key_unique on public.ai_usage(usage_key);
create index if not exists ai_usage_user_created_idx on public.ai_usage(user_id, created_at desc);
create index if not exists ai_usage_campaign_created_idx on public.ai_usage(campaign_id, created_at desc);
create table if not exists public.ai_quota_events (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.profiles(id) on delete cascade,
campaign_id uuid references public.campaigns(id) on delete cascade,
request_kind text not null check (char_length(request_kind) between 1 and 80),
created_at timestamptz not null default now()
);
create index if not exists ai_quota_events_user_created_idx on public.ai_quota_events(user_id, created_at desc);
create index if not exists ai_quota_events_campaign_created_idx on public.ai_quota_events(campaign_id, created_at desc);
alter table public.ai_quota_events enable row level security;
drop policy if exists quota_events_self_read on public.ai_quota_events;
create policy quota_events_self_read on public.ai_quota_events for select using (user_id = auth.uid());
create table if not exists public.scene_states (
campaign_id uuid primary key references public.campaigns(id) on delete cascade,
location_id uuid references public.world_entities(id) on delete set null,
summary text not null check (char_length(summary) between 1 and 6000),
active_entity_ids uuid[] not null default '{}',
tags text[] not null default '{}',
through_round integer not null default 0 check (through_round >= 0),
updated_at timestamptz not null default now()
);
create table if not exists public.quest_states (
campaign_id uuid not null references public.campaigns(id) on delete cascade,
quest_entity_id uuid not null references public.world_entities(id) on delete cascade,
status text not null default 'hidden' check (status in ('hidden', 'active', 'completed', 'failed')),
summary text not null check (char_length(summary) between 1 and 1200),
tags text[] not null default '{}',
through_round integer not null default 0 check (through_round >= 0),
updated_at timestamptz not null default now(),
primary key (campaign_id, quest_entity_id)
);
create index if not exists quest_states_campaign_status_idx on public.quest_states(campaign_id, status, updated_at desc);
create or replace function public.stage_six_ensure_opening_quest()
returns trigger language plpgsql security definer set search_path = '' as $$
begin
if nullif(btrim(new.hook), '') is not null and not exists (
select 1 from public.world_entities where world_id = new.id and kind = 'quest'
) then
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
values (new.id, 'quest', 'Opening objective', left(btrim(new.hook), 1200), array['opening', 'active'], '[]'::jsonb);
end if;
return new;
end;
$$;
drop trigger if exists stage_six_world_opening_quest on public.worlds;
create trigger stage_six_world_opening_quest
after insert or update of hook on public.worlds
for each row execute function public.stage_six_ensure_opening_quest();
insert into public.world_entities(world_id, kind, name, summary, tags, secrets)
select world.id, 'quest', 'Opening objective', left(btrim(world.hook), 1200), array['opening', 'active'], '[]'::jsonb
from public.worlds world
where nullif(btrim(world.hook), '') is not null
and not exists (select 1 from public.world_entities entity where entity.world_id = world.id and entity.kind = 'quest');
alter table public.scene_states enable row level security;
alter table public.quest_states enable row level security;
drop policy if exists scene_states_member_read on public.scene_states;
drop policy if exists quest_states_member_read on public.quest_states;
create policy scene_states_member_read on public.scene_states for select using (
public.is_campaign_member(campaign_id) or public.is_campaign_owner(campaign_id)
);
create policy quest_states_member_read on public.quest_states for select using (
public.is_campaign_member(campaign_id) or public.is_campaign_owner(campaign_id)
);
insert into public.scene_states(campaign_id, summary)
select id, current_scene from public.campaigns
on conflict (campaign_id) do nothing;
insert into public.quest_states(campaign_id, quest_entity_id, status, summary, tags)
select campaign.id, entity.id, case when 'opening' = any(entity.tags) then 'active' else 'hidden' end, entity.summary, entity.tags
from public.campaigns campaign
join public.world_entities entity on entity.world_id = campaign.world_id and entity.kind = 'quest'
on conflict (campaign_id, quest_entity_id) do nothing;
create or replace function public.stage_six_initialize_campaign_state()
returns trigger language plpgsql security definer set search_path = '' as $$
begin
insert into public.scene_states(campaign_id, summary)
values (new.id, new.current_scene)
on conflict (campaign_id) do nothing;
insert into public.quest_states(campaign_id, quest_entity_id, status, summary, tags)
select new.id, entity.id, case when 'opening' = any(entity.tags) then 'active' else 'hidden' end, entity.summary, entity.tags
from public.world_entities entity
where entity.world_id = new.world_id and entity.kind = 'quest'
on conflict (campaign_id, quest_entity_id) do nothing;
return new;
end;
$$;
drop trigger if exists stage_six_campaign_state on public.campaigns;
create trigger stage_six_campaign_state
after insert on public.campaigns
for each row execute function public.stage_six_initialize_campaign_state();
create or replace function public.stage_six_consume_ai_quota(
p_user_id uuid,
p_campaign_id uuid,
p_request_kind text
) returns jsonb language plpgsql security definer set search_path = '' as $$
declare
v_user_minute integer;
v_user_day integer;
v_campaign_day integer := 0;
v_tokens_day bigint;
begin
if p_user_id is null or not exists (select 1 from public.profiles where id = p_user_id) then
raise exception 'AI quota requires a valid user';
end if;
if p_campaign_id is not null and not exists (
select 1 from public.campaigns where id = p_campaign_id
and (owner_id = p_user_id or exists (
select 1 from public.campaign_members where campaign_id = p_campaign_id and user_id = p_user_id and active
))
) then
raise exception 'AI quota campaign access denied';
end if;
if p_request_kind is null or char_length(btrim(p_request_kind)) not between 1 and 80 then
raise exception 'AI request kind is required';
end if;
perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('ai-quota:' || p_user_id::text, 0));
select count(*) into v_user_minute from public.ai_quota_events
where user_id = p_user_id and created_at >= now() - interval '1 minute';
select count(*) into v_user_day from public.ai_quota_events
where user_id = p_user_id and created_at >= date_trunc('day', now());
select coalesce(sum(input_tokens + output_tokens), 0) into v_tokens_day from public.ai_usage
where user_id = p_user_id and created_at >= date_trunc('day', now());
if p_campaign_id is not null then
select count(*) into v_campaign_day from public.ai_quota_events
where campaign_id = p_campaign_id and created_at >= date_trunc('day', now());
end if;
if v_user_minute >= 10 then raise exception 'AI rate limit reached; try again in a minute'; end if;
if v_user_day >= 60 then raise exception 'daily user AI quota reached'; end if;
if p_campaign_id is not null and v_campaign_day >= 40 then raise exception 'daily campaign AI quota reached'; end if;
if v_tokens_day >= 250000 then raise exception 'daily AI token quota reached'; end if;
insert into public.ai_quota_events(user_id, campaign_id, request_kind)
values (p_user_id, p_campaign_id, btrim(p_request_kind));
return jsonb_build_object(
'userRemaining', 59 - v_user_day,
'campaignRemaining', case when p_campaign_id is null then null else 39 - v_campaign_day end,
'tokenRemaining', greatest(0, 250000 - v_tokens_day)
);
end;
$$;
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;
v_owner_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 owner_id into v_owner_id from public.campaigns where id = v_round.campaign_id;
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 p_forced_by <> v_owner_id then raise exception 'only the campaign owner can force a round'; end if;
elsif 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
raise exception 'not all active players are ready';
end if;
perform public.stage_six_consume_ai_quota(v_owner_id, v_round.campaign_id, 'resolve-round');
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;
$$;
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;
v_existing boolean := false;
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;
v_existing := found;
if v_existing and v_job.status in ('queued', 'running') then return v_job.id; end if;
perform public.stage_six_consume_ai_quota(p_owner_id, null, 'generate-world');
if v_existing then
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_six_retrieve_context(
p_campaign_id uuid,
p_search_text text,
p_limit integer default 8
) returns jsonb language plpgsql stable security definer set search_path = '' as $$
declare
v_world_id uuid;
v_query tsquery;
v_limit integer := greatest(1, least(coalesce(p_limit, 8), 12));
v_entities jsonb;
v_entity_ids uuid[];
begin
select world_id into v_world_id from public.campaigns where id = p_campaign_id;
if not found then raise exception 'campaign not found'; end if;
v_query := plainto_tsquery('english', left(coalesce(p_search_text, ''), 4000));
select coalesce(jsonb_agg(item.payload order by item.relevance desc, item.name), '[]'::jsonb),
coalesce(array_agg(item.id order by item.relevance desc, item.name), '{}')
into v_entities, v_entity_ids
from (
select entity.id, entity.name,
jsonb_build_object('id', entity.id, 'kind', entity.kind, 'name', entity.name, 'summary', entity.summary, 'tags', entity.tags) as payload,
case when numnode(v_query) > 0 then ts_rank_cd(entity.search_document, v_query) else 0 end
+ case when exists (select 1 from unnest(entity.tags) tag where position(lower(tag) in lower(coalesce(p_search_text, ''))) > 0) then 2 else 0 end
+ case when quest.status = 'active' then 3 else 0 end
+ case when scene.location_id = entity.id or entity.id = any(scene.active_entity_ids) then 4 else 0 end as relevance
from public.world_entities entity
left join public.quest_states quest on quest.campaign_id = p_campaign_id and quest.quest_entity_id = entity.id
left join public.scene_states scene on scene.campaign_id = p_campaign_id
where entity.world_id = v_world_id
order by relevance desc, entity.created_at
limit v_limit
) item;
return jsonb_build_object(
'entities', v_entities,
'memories', coalesce((
select jsonb_agg(memory_item.payload order by memory_item.relevance desc, memory_item.created_at desc)
from (
select memory.created_at,
jsonb_build_object('summary', memory.summary, 'importance', memory.importance, 'tags', memory.tags, 'entityIds', memory.entity_ids) as payload,
memory.importance
+ case when numnode(v_query) > 0 then ts_rank_cd(memory.search_document, v_query) * 10 else 0 end
+ case when memory.entity_ids && v_entity_ids then 5 else 0 end
+ case when exists (select 1 from unnest(memory.tags) tag where position(lower(tag) in lower(coalesce(p_search_text, ''))) > 0) then 4 else 0 end as relevance
from public.memories memory
where memory.campaign_id = p_campaign_id
order by relevance desc, memory.created_at desc
limit v_limit
) memory_item
), '[]'::jsonb),
'relationships', coalesce((
select jsonb_agg(jsonb_build_object(
'sourceEntityId', item.source_entity_id,
'targetEntityId', item.target_entity_id,
'score', item.score,
'notes', item.notes
) order by abs(item.score) desc)
from (
select relationship.* from public.relationships relationship
where relationship.campaign_id = p_campaign_id
and (
relationship.source_entity_id = any(v_entity_ids)
or relationship.target_entity_id = any(v_entity_ids)
or exists (
select 1 from public.characters character
where character.campaign_id = p_campaign_id
and character.id in (relationship.source_entity_id, relationship.target_entity_id)
)
)
order by abs(relationship.score) desc
limit 20
) item
), '[]'::jsonb),
'activeGoals', coalesce((
select jsonb_agg(jsonb_build_object(
'questEntityId', item.quest_entity_id,
'name', item.name,
'status', item.status,
'summary', item.summary,
'tags', item.tags
) order by item.updated_at desc)
from (
select quest.*, entity.name from public.quest_states quest
join public.world_entities entity on entity.id = quest.quest_entity_id
where quest.campaign_id = p_campaign_id and quest.status = 'active'
order by quest.updated_at desc
limit 8
) item
), '[]'::jsonb)
);
end;
$$;
create or replace function public.stage_six_apply_round_projections(
p_round_id uuid,
p_events jsonb
) returns void language plpgsql security definer set search_path = '' as $$
declare
v_round public.rounds%rowtype;
v_event jsonb;
v_actor uuid;
v_target uuid;
v_before jsonb;
v_after jsonb;
v_scene_projected boolean := false;
begin
select * into v_round from public.rounds where id = p_round_id;
if not found then raise exception 'round not found'; end if;
for v_event in select * from jsonb_array_elements(coalesce(p_events, '[]'::jsonb)) loop
v_actor := nullif(v_event->>'actorId', '')::uuid;
v_target := nullif(v_event->>'targetId', '')::uuid;
if v_event->>'type' = 'relationship' then
if v_actor is null or v_target is null or v_actor = v_target
or (v_event->>'value')::integer not between -20 and 20 then
raise exception 'invalid relationship projection';
end if;
select to_jsonb(relationship) into v_before from public.relationships relationship
where campaign_id = v_round.campaign_id and source_entity_id = v_actor and target_entity_id = v_target;
insert into public.relationships(campaign_id, source_entity_id, target_entity_id, score, notes)
values (v_round.campaign_id, v_actor, v_target, (v_event->>'value')::integer, v_event->>'description')
on conflict (campaign_id, source_entity_id, target_entity_id) do update
set score = greatest(-100, least(100, public.relationships.score + excluded.score)), notes = excluded.notes;
select to_jsonb(relationship) into v_after from public.relationships relationship
where campaign_id = v_round.campaign_id and source_entity_id = v_actor and target_entity_id = v_target;
insert into public.audit_entries(campaign_id, action, entity_type, entity_id, before_state, after_state)
values (v_round.campaign_id, 'project_relationship', 'relationship', v_target, v_before, v_after);
elsif v_event->>'type' = 'quest' then
if v_target is null or v_event->>'item' not in ('hidden', 'active', 'completed', 'failed') then
raise exception 'invalid quest projection';
end if;
select to_jsonb(quest) into v_before from public.quest_states quest
where campaign_id = v_round.campaign_id and quest_entity_id = v_target;
update public.quest_states set status = v_event->>'item', summary = v_event->>'description',
through_round = v_round.number, updated_at = now()
where campaign_id = v_round.campaign_id and quest_entity_id = v_target;
if not found then raise exception 'quest is outside the round campaign'; end if;
select to_jsonb(quest) into v_after from public.quest_states quest
where campaign_id = v_round.campaign_id and quest_entity_id = v_target;
insert into public.audit_entries(campaign_id, action, entity_type, entity_id, before_state, after_state)
values (v_round.campaign_id, 'project_quest', 'quest', v_target, v_before, v_after);
elsif v_event->>'type' = 'narrative' and v_event->>'item' = 'scene' then
v_scene_projected := true;
if v_target is not null and not exists (
select 1 from public.world_entities entity join public.campaigns campaign on campaign.world_id = entity.world_id
where campaign.id = v_round.campaign_id and entity.id = v_target and entity.kind = 'location'
) then raise exception 'scene location is outside the campaign world'; end if;
insert into public.scene_states(campaign_id, location_id, summary, active_entity_ids, through_round, updated_at)
values (
v_round.campaign_id, v_target, v_event->>'description',
array_remove(array[v_actor, v_target], null), v_round.number, now()
)
on conflict (campaign_id) do update set location_id = excluded.location_id,
summary = excluded.summary, active_entity_ids = excluded.active_entity_ids,
through_round = excluded.through_round, updated_at = excluded.updated_at;
update public.campaigns set current_scene = v_event->>'description', updated_at = now()
where id = v_round.campaign_id;
end if;
end loop;
if not v_scene_projected and nullif(btrim(v_round.narration), '') is not null then
insert into public.scene_states(campaign_id, summary, through_round, updated_at)
values (v_round.campaign_id, v_round.narration, v_round.number, now())
on conflict (campaign_id) do update set summary = excluded.summary,
through_round = excluded.through_round, updated_at = excluded.updated_at;
end if;
end;
$$;
create or replace function public.commit_stage_six_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_story_summary text,
p_idempotency_key text
) returns void language plpgsql security definer set search_path = '' as $$
declare v_round public.rounds%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 = 'resolved' then return; end if;
perform public.commit_srd_round_resolution(
p_round_id, p_narration, p_next_prompt, p_rolls, p_events,
p_character_states, p_memory, p_idempotency_key
);
perform public.stage_six_apply_round_projections(p_round_id, p_events);
if p_story_summary is not null then
if v_round.number % 3 <> 0 or char_length(btrim(p_story_summary)) not between 20 and 6000 then
raise exception 'invalid scheduled story summary';
end if;
insert into public.story_summaries(campaign_id, through_round, summary)
values (v_round.campaign_id, v_round.number, btrim(p_story_summary))
on conflict (campaign_id, through_round) do update set summary = excluded.summary;
end if;
end;
$$;
create or replace function public.commit_claimed_stage_six_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_story_summary text,
p_idempotency_key text,
p_worker_id text
) returns void language plpgsql security definer set search_path = '' as $$
declare v_round public.rounds%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 = 'resolved' then return; end if;
perform public.commit_claimed_srd_round_resolution(
p_round_id, p_narration, p_next_prompt, p_rolls, p_events,
p_character_states, p_memory, p_idempotency_key, p_worker_id
);
perform public.stage_six_apply_round_projections(p_round_id, p_events);
if p_story_summary is not null then
if v_round.number % 3 <> 0 or char_length(btrim(p_story_summary)) not between 20 and 6000 then
raise exception 'invalid scheduled story summary';
end if;
insert into public.story_summaries(campaign_id, through_round, summary)
values (v_round.campaign_id, v_round.number, btrim(p_story_summary))
on conflict (campaign_id, through_round) do update set summary = excluded.summary;
end if;
end;
$$;
create or replace function public.stage_six_adjust_character_state(
p_campaign_id uuid,
p_owner_id uuid,
p_character_id uuid,
p_patch jsonb,
p_reason text
) returns jsonb language plpgsql security definer set search_path = '' as $$
declare
v_character public.characters%rowtype;
v_before jsonb;
v_after jsonb;
v_hp integer;
begin
if not exists (select 1 from public.campaigns where id = p_campaign_id and owner_id = p_owner_id) then
raise exception 'only the campaign owner can adjust state';
end if;
if p_reason is null or char_length(btrim(p_reason)) not between 3 and 500 then raise exception 'an audit reason is required'; end if;
if coalesce(jsonb_typeof(p_patch), '') <> 'object' or p_patch - array['hp', 'inventory', 'statuses']::text[] <> '{}'::jsonb then
raise exception 'only hp, inventory, and statuses may be adjusted';
end if;
select * into v_character from public.characters
where id = p_character_id and campaign_id = p_campaign_id for update;
if not found then raise exception 'character not found'; end if;
v_before := jsonb_build_object('hp', v_character.hp, 'inventory', v_character.inventory, 'statuses', v_character.statuses);
v_hp := coalesce((p_patch->>'hp')::integer, v_character.hp);
if v_hp < 0 or v_hp > v_character.max_hp then raise exception 'hp must be between zero and max hp'; end if;
if p_patch ? 'inventory' and jsonb_typeof(p_patch->'inventory') <> 'array' then raise exception 'inventory must be an array'; end if;
if p_patch ? 'statuses' and jsonb_typeof(p_patch->'statuses') <> 'array' then raise exception 'statuses must be an array'; end if;
update public.characters set hp = v_hp,
inventory = coalesce(p_patch->'inventory', inventory),
statuses = coalesce(p_patch->'statuses', statuses)
where id = p_character_id
returning jsonb_build_object('hp', hp, 'inventory', inventory, 'statuses', statuses) into v_after;
insert into public.audit_entries(campaign_id, actor_id, action, entity_type, entity_id, before_state, after_state)
values (p_campaign_id, p_owner_id, 'manual_character_adjustment: ' || btrim(p_reason), 'character', p_character_id, v_before, v_after);
return v_after;
end;
$$;
create or replace function public.dng_schema_version()
returns integer language sql stable security definer set search_path = '' as $$
select 13;
$$;
revoke all on function public.stage_six_consume_ai_quota(uuid, uuid, text) from public, anon, authenticated;
revoke all on function public.stage_six_retrieve_context(uuid, text, integer) from public, anon, authenticated;
revoke all on function public.stage_six_apply_round_projections(uuid, jsonb) from public, anon, authenticated;
revoke all on function public.commit_stage_six_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) from public, anon, authenticated;
revoke all on function public.commit_claimed_stage_six_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text, text) from public, anon, authenticated;
revoke all on function public.stage_six_adjust_character_state(uuid, uuid, uuid, jsonb, text) from public, anon, authenticated;
revoke all on function public.dng_schema_version() from public, anon, authenticated;
grant execute on function public.stage_six_consume_ai_quota(uuid, uuid, text) to service_role;
grant execute on function public.stage_six_retrieve_context(uuid, text, integer) to service_role;
grant execute on function public.commit_stage_six_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text) to service_role;
grant execute on function public.commit_claimed_stage_six_round_resolution(uuid, text, text, jsonb, jsonb, jsonb, jsonb, text, text, text) to service_role;
grant execute on function public.stage_six_adjust_character_state(uuid, uuid, uuid, jsonb, text) to service_role;
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() <> 13 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;