File size: 8,909 Bytes
3493993
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
-- Provider-neutral generation foundation.
--
-- Apply after 0002_authoritative_tenancy_postgres.sql.  This migration adds
-- durable generation intent and execution records only; it does not configure
-- WAN, FLUX, external worker endpoints, or any credentials.

begin;

create table if not exists generation_requests (
  id text primary key default gen_random_uuid()::text,
  workspace_id text not null references workspaces(id) on delete restrict,
  created_by_user_id text not null references users(id) on delete restrict,
  provider text not null check (provider ~ '^[a-z][a-z0-9_-]{0,63}$'),
  model_id text not null,
  modality text not null check (modality in ('image', 'video')),
  input_asset_id text references media_assets(id) on delete restrict,
  spec jsonb not null default '{}'::jsonb,
  request_fingerprint char(64) not null check (request_fingerprint ~ '^[0-9a-f]{64}$'),
  idempotency_key text not null,
  status text not null default 'queued' check (status in (
    'queued', 'submitting', 'running', 'retrying', 'succeeded', 'failed',
    'cancel_requested', 'cancelled'
  )),
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  completed_at timestamptz,
  constraint uq_generation_request_workspace_idempotency unique (workspace_id, idempotency_key)
);
create index if not exists ix_generation_requests_workspace_created
  on generation_requests(workspace_id, created_at desc);
create index if not exists ix_generation_requests_workspace_status
  on generation_requests(workspace_id, status);

create table if not exists generation_jobs (
  id text primary key default gen_random_uuid()::text,
  generation_request_id text not null unique references generation_requests(id) on delete cascade,
  workspace_id text not null references workspaces(id) on delete restrict,
  provider text not null check (provider ~ '^[a-z][a-z0-9_-]{0,63}$'),
  status text not null default 'queued' check (status in (
    'queued', 'submitting', 'running', 'retrying', 'succeeded', 'failed',
    'cancel_requested', 'cancelled'
  )),
  attempt_count integer not null default 0 check (attempt_count >= 0),
  max_attempts integer not null default 3 check (max_attempts >= 0),
  next_attempt_at timestamptz,
  external_job_id text,
  provider_metadata jsonb not null default '{}'::jsonb,
  output_asset_id text references media_assets(id) on delete restrict,
  error_code text,
  error_message text,
  created_at timestamptz not null default now(),
  started_at timestamptz,
  completed_at timestamptz,
  updated_at timestamptz not null default now()
);
create index if not exists ix_generation_jobs_workspace_status
  on generation_jobs(workspace_id, status);
create index if not exists ix_generation_jobs_next_attempt
  on generation_jobs(status, next_attempt_at);
create index if not exists ix_generation_jobs_external
  on generation_jobs(provider, external_job_id) where external_job_id is not null;

create table if not exists generation_job_attempts (
  id text primary key default gen_random_uuid()::text,
  generation_job_id text not null references generation_jobs(id) on delete cascade,
  attempt_number integer not null check (attempt_number > 0),
  status text not null,
  error_code text,
  error_message text,
  provider_request_id text,
  started_at timestamptz not null default now(),
  completed_at timestamptz,
  constraint uq_generation_job_attempt_number unique (generation_job_id, attempt_number)
);
create index if not exists ix_generation_job_attempts_job
  on generation_job_attempts(generation_job_id, attempt_number);

-- Keep denormalised workspace IDs and canonical input/output references
-- internally consistent even when a trusted service role writes the rows.
create or replace function mediarouter_assert_generation_request_asset_workspace()
returns trigger language plpgsql as $$
declare asset_workspace text;
begin
  if new.input_asset_id is not null then
    select workspace_id into asset_workspace from media_assets where id = new.input_asset_id;
    if asset_workspace is null or asset_workspace is distinct from new.workspace_id then
      raise exception 'generation input asset must belong to its request workspace' using errcode = '23503';
    end if;
  end if;
  return new;
end;
$$;
drop trigger if exists mediarouter_generation_request_asset_workspace on generation_requests;
create trigger mediarouter_generation_request_asset_workspace
before insert or update of workspace_id, input_asset_id on generation_requests
for each row execute function mediarouter_assert_generation_request_asset_workspace();

create or replace function mediarouter_assert_generation_job_workspace()
returns trigger language plpgsql as $$
declare request_workspace text;
declare asset_workspace text;
begin
  select workspace_id into request_workspace from generation_requests where id = new.generation_request_id;
  if request_workspace is null or request_workspace is distinct from new.workspace_id then
    raise exception 'generation job must belong to its request workspace' using errcode = '23503';
  end if;
  if new.output_asset_id is not null then
    select workspace_id into asset_workspace from media_assets where id = new.output_asset_id;
    if asset_workspace is null or asset_workspace is distinct from new.workspace_id then
      raise exception 'generation output asset must belong to its job workspace' using errcode = '23503';
    end if;
  end if;
  return new;
end;
$$;
drop trigger if exists mediarouter_generation_job_workspace on generation_jobs;
create trigger mediarouter_generation_job_workspace
before insert or update of workspace_id, generation_request_id, output_asset_id on generation_jobs
for each row execute function mediarouter_assert_generation_job_workspace();

do $$
declare table_name text;
begin
  foreach table_name in array array['generation_requests', 'generation_jobs'] loop
    execute format('drop trigger if exists mediarouter_tenant_touch_updated_at on %I', table_name);
    execute format('create trigger mediarouter_tenant_touch_updated_at before update on %I for each row execute function mediarouter_tenant_touch_updated_at()', table_name);
  end loop;
end $$;

-- API paths set app.workspace_id and app.user_id through SecurityDatabase's
-- tenant session.  The security/service role is backend-only; these policies
-- remain defence in depth and independently verifiable with a tenant role.
alter table generation_requests enable row level security;
alter table generation_requests force row level security;
drop policy if exists generation_requests_workspace_isolation on generation_requests;
create policy generation_requests_workspace_isolation on generation_requests
  using (
    workspace_id = current_setting('app.workspace_id', true)
    and exists (
      select 1 from workspace_memberships m
      where m.workspace_id = generation_requests.workspace_id
        and m.user_id = current_setting('app.user_id', true)
        and m.status = 'active'
    )
  )
  with check (
    workspace_id = current_setting('app.workspace_id', true)
    and created_by_user_id = current_setting('app.user_id', true)
    and exists (
      select 1 from workspace_memberships m
      where m.workspace_id = generation_requests.workspace_id
        and m.user_id = current_setting('app.user_id', true)
        and m.status = 'active'
    )
  );

alter table generation_jobs enable row level security;
alter table generation_jobs force row level security;
drop policy if exists generation_jobs_workspace_isolation on generation_jobs;
create policy generation_jobs_workspace_isolation on generation_jobs
  using (
    workspace_id = current_setting('app.workspace_id', true)
    and exists (
      select 1 from workspace_memberships m
      where m.workspace_id = generation_jobs.workspace_id
        and m.user_id = current_setting('app.user_id', true)
        and m.status = 'active'
    )
  )
  with check (
    workspace_id = current_setting('app.workspace_id', true)
    and exists (
      select 1 from workspace_memberships m
      where m.workspace_id = generation_jobs.workspace_id
        and m.user_id = current_setting('app.user_id', true)
        and m.status = 'active'
    )
  );

alter table generation_job_attempts enable row level security;
alter table generation_job_attempts force row level security;
drop policy if exists generation_job_attempts_workspace_isolation on generation_job_attempts;
create policy generation_job_attempts_workspace_isolation on generation_job_attempts
  using (exists (
    select 1 from generation_jobs j
    where j.id = generation_job_attempts.generation_job_id
      and j.workspace_id = current_setting('app.workspace_id', true)
  ))
  with check (exists (
    select 1 from generation_jobs j
    where j.id = generation_job_attempts.generation_job_id
      and j.workspace_id = current_setting('app.workspace_id', true)
  ));

commit;