-- 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';