File size: 13,116 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
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
-- Content Studio Phase 2: authoritative editor state and project render jobs.
-- Apply after projects/0001_projects_foundation.sql, projects/0002_project_resources.sql,
-- and security generation migrations.  Production migration remains explicit.

begin;

create table if not exists project_editor_states (
  id text primary key default gen_random_uuid()::text,
  workspace_id text not null references workspaces(id) on delete restrict,
  project_id text not null references projects(id) on delete restrict,
  revision integer not null default 1 check (revision > 0),
  schema_version integer not null check (schema_version = 1),
  state jsonb not null check (jsonb_typeof(state) = 'object'),
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  updated_by text not null references users(id) on delete restrict,
  constraint uq_project_editor_state_project unique (project_id)
);
create index if not exists ix_project_editor_states_workspace_project
  on project_editor_states(workspace_id, project_id);

create table if not exists project_render_jobs (
  id text primary key default gen_random_uuid()::text,
  workspace_id text not null references workspaces(id) on delete restrict,
  project_id text not null references projects(id) on delete restrict,
  editor_revision integer not null check (editor_revision > 0),
  editor_schema_version integer not null check (editor_schema_version = 1),
  editor_state jsonb not null check (jsonb_typeof(editor_state) = 'object'),
  render_settings jsonb not null check (jsonb_typeof(render_settings) = 'object'),
  request_fingerprint text not null check (char_length(request_fingerprint) = 64),
  idempotency_key text not null check (char_length(idempotency_key) between 1 and 255),
  requested_by text not null references users(id) on delete restrict,
  status text not null default 'queued'
    check (status in ('queued', 'processing', 'completed', 'failed', 'cancelling', 'cancelled')),
  attempt_count integer not null default 0 check (attempt_count >= 0),
  max_attempts integer not null default 3 check (max_attempts between 1 and 10),
  next_attempt_at timestamptz,
  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,
  cancelled_at timestamptz,
  updated_at timestamptz not null default now(),
  constraint uq_project_render_idempotency unique (project_id, editor_revision, idempotency_key)
);
create index if not exists ix_project_render_jobs_workspace_status
  on project_render_jobs(workspace_id, status);
create index if not exists ix_project_render_jobs_project_created
  on project_render_jobs(project_id, created_at desc);
create index if not exists ix_project_render_jobs_dispatch
  on project_render_jobs(status, next_attempt_at);

drop trigger if exists mediarouter_project_editor_touch_updated_at on project_editor_states;
create trigger mediarouter_project_editor_touch_updated_at
before update on project_editor_states
for each row execute function mediarouter_tenant_touch_updated_at();
drop trigger if exists mediarouter_project_render_touch_updated_at on project_render_jobs;
create trigger mediarouter_project_render_touch_updated_at
before update on project_render_jobs
for each row execute function mediarouter_tenant_touch_updated_at();

create or replace function mediarouter_assert_project_editor_ownership()
returns trigger language plpgsql as $$
declare project_workspace text;
declare project_status text;
begin
  if tg_op = 'INSERT' and new.revision <> 1 then
    raise exception 'initial editor revision must be one' using errcode = '23514';
  end if;
  if tg_op = 'UPDATE' and (
    new.workspace_id is distinct from old.workspace_id
    or new.project_id is distinct from old.project_id
    or new.created_at is distinct from old.created_at
  ) then
    raise exception 'project editor ownership fields are immutable' using errcode = '23514';
  end if;
  if tg_op = 'UPDATE' and new.revision <> old.revision + 1 then
    raise exception 'editor revision must increment exactly once' using errcode = '23514';
  end if;
  if new.state->>'projectId' is distinct from new.project_id
     or new.state->>'schemaVersion' is distinct from new.schema_version::text then
    raise exception 'editor state identity does not match its row' using errcode = '23514';
  end if;
  select workspace_id, status into project_workspace, project_status from projects where id = new.project_id;
  if project_workspace is null or project_workspace is distinct from new.workspace_id then
    raise exception 'editor state must belong to its project workspace' using errcode = '23503';
  end if;
  if project_status is distinct from 'active' then
    raise exception 'archived projects cannot change editor state' using errcode = '23514';
  end if;
  if not exists (
    select 1 from workspace_memberships m
    where m.workspace_id = new.workspace_id and m.user_id = new.updated_by and m.status = 'active'
  ) then
    raise exception 'editor state update requires active workspace membership' using errcode = '23503';
  end if;
  return new;
end;
$$;
drop trigger if exists mediarouter_project_editor_ownership on project_editor_states;
create trigger mediarouter_project_editor_ownership
before insert or update on project_editor_states
for each row execute function mediarouter_assert_project_editor_ownership();

create or replace function mediarouter_assert_project_render_ownership()
returns trigger language plpgsql as $$
declare project_workspace text;
declare project_status text;
declare output_workspace text;
declare output_project text;
begin
  if tg_op = 'UPDATE' and (
    new.workspace_id is distinct from old.workspace_id
    or new.project_id is distinct from old.project_id
    or new.editor_revision is distinct from old.editor_revision
    or new.editor_schema_version is distinct from old.editor_schema_version
    or new.editor_state is distinct from old.editor_state
    or new.render_settings is distinct from old.render_settings
    or new.request_fingerprint is distinct from old.request_fingerprint
    or new.idempotency_key is distinct from old.idempotency_key
    or new.requested_by is distinct from old.requested_by
    or new.max_attempts is distinct from old.max_attempts
    or new.created_at is distinct from old.created_at
  ) then
    raise exception 'render identity fields are immutable' using errcode = '23514';
  end if;
  if new.editor_state->>'projectId' is distinct from new.project_id
     or new.editor_state->>'schemaVersion' is distinct from new.editor_schema_version::text then
    raise exception 'render editor snapshot identity does not match its row' using errcode = '23514';
  end if;
  if tg_op = 'UPDATE' and new.status is distinct from old.status and not (
    (old.status = 'queued' and new.status in ('processing', 'cancelled', 'failed'))
    or (old.status = 'processing' and new.status in ('queued', 'completed', 'failed', 'cancelling', 'cancelled'))
    or (old.status = 'cancelling' and new.status in ('completed', 'failed', 'cancelled'))
  ) then
    raise exception 'invalid render status transition' using errcode = '23514';
  end if;
  select workspace_id, status into project_workspace, project_status from projects where id = new.project_id;
  if project_workspace is null or project_workspace is distinct from new.workspace_id then
    raise exception 'render must belong to its project workspace' using errcode = '23503';
  end if;
  if tg_op = 'INSERT' and project_status is distinct from 'active' then
    raise exception 'archived projects cannot be rendered' using errcode = '23514';
  end if;
  if new.output_asset_id is not null then
    select workspace_id, project_id into output_workspace, output_project
      from media_assets where id = new.output_asset_id;
    if output_workspace is null
       or output_workspace is distinct from new.workspace_id
       or output_project is distinct from new.project_id then
      raise exception 'render output must belong to its project workspace' using errcode = '23503';
    end if;
  end if;
  if new.status = 'completed' and (new.output_asset_id is null or new.completed_at is null) then
    raise exception 'completed render requires an output asset and timestamp' using errcode = '23514';
  end if;
  if new.status = 'cancelled' and (new.cancelled_at is null or new.completed_at is null) then
    raise exception 'cancelled render requires cancellation timestamps' using errcode = '23514';
  end if;
  if new.status = 'failed' and new.completed_at is null then
    raise exception 'failed render requires a completion timestamp' using errcode = '23514';
  end if;
  if new.status in ('queued', 'processing', 'cancelling') and new.output_asset_id is not null then
    raise exception 'active render cannot expose an output asset' using errcode = '23514';
  end if;
  if tg_op = 'INSERT' and not exists (
    select 1 from workspace_memberships m
    where m.workspace_id = new.workspace_id and m.user_id = new.requested_by and m.status = 'active'
  ) then
    raise exception 'render requires active workspace membership' using errcode = '23503';
  end if;
  return new;
end;
$$;
drop trigger if exists mediarouter_project_render_ownership on project_render_jobs;
create trigger mediarouter_project_render_ownership
before insert or update on project_render_jobs
for each row execute function mediarouter_assert_project_render_ownership();

alter table project_editor_states enable row level security;
alter table project_editor_states force row level security;
drop policy if exists project_editor_states_select on project_editor_states;
create policy project_editor_states_select on project_editor_states for select using (
  workspace_id = current_setting('app.workspace_id', true)
  and exists (select 1 from workspace_memberships m where m.workspace_id = project_editor_states.workspace_id
    and m.user_id = current_setting('app.user_id', true) and m.status = 'active')
);
drop policy if exists project_editor_states_insert on project_editor_states;
create policy project_editor_states_insert on project_editor_states for insert with check (
  workspace_id = current_setting('app.workspace_id', true)
  and updated_by = current_setting('app.user_id', true)
  and exists (select 1 from workspace_memberships m where m.workspace_id = project_editor_states.workspace_id
    and m.user_id = current_setting('app.user_id', true) and m.status = 'active' and m.role <> 'viewer')
);
drop policy if exists project_editor_states_update on project_editor_states;
create policy project_editor_states_update on project_editor_states for update using (
  workspace_id = current_setting('app.workspace_id', true)
  and exists (select 1 from workspace_memberships m where m.workspace_id = project_editor_states.workspace_id
    and m.user_id = current_setting('app.user_id', true) and m.status = 'active' and m.role <> 'viewer')
) with check (
  workspace_id = current_setting('app.workspace_id', true)
  and updated_by = current_setting('app.user_id', true)
  and exists (select 1 from workspace_memberships m where m.workspace_id = project_editor_states.workspace_id
    and m.user_id = current_setting('app.user_id', true) and m.status = 'active' and m.role <> 'viewer')
);

alter table project_render_jobs enable row level security;
alter table project_render_jobs force row level security;
drop policy if exists project_render_jobs_select on project_render_jobs;
create policy project_render_jobs_select on project_render_jobs for select using (
  workspace_id = current_setting('app.workspace_id', true)
  and exists (select 1 from workspace_memberships m where m.workspace_id = project_render_jobs.workspace_id
    and m.user_id = current_setting('app.user_id', true) and m.status = 'active')
);
drop policy if exists project_render_jobs_insert on project_render_jobs;
create policy project_render_jobs_insert on project_render_jobs for insert with check (
  workspace_id = current_setting('app.workspace_id', true)
  and requested_by = current_setting('app.user_id', true)
  and exists (select 1 from workspace_memberships m where m.workspace_id = project_render_jobs.workspace_id
    and m.user_id = current_setting('app.user_id', true) and m.status = 'active' and m.role <> 'viewer')
);
drop policy if exists project_render_jobs_update on project_render_jobs;
create policy project_render_jobs_update on project_render_jobs for update using (
  workspace_id = current_setting('app.workspace_id', true)
  and exists (select 1 from workspace_memberships m where m.workspace_id = project_render_jobs.workspace_id
    and m.user_id = current_setting('app.user_id', true) and m.status = 'active' and m.role <> 'viewer')
) with check (
  workspace_id = current_setting('app.workspace_id', true)
  and exists (select 1 from workspace_memberships m where m.workspace_id = project_render_jobs.workspace_id
    and m.user_id = current_setting('app.user_id', true) and m.status = 'active' and m.role <> 'viewer')
);

commit;