T-HUB/apps/api/migrations/018_publishing_execution.sql
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

485 lines
26 KiB
PL/PgSQL

-- 018: WP3 publishing execution spine (fences, attempt journal, receipts).
-- Preserves 006: same drafts / scheduled_publications / publications tables,
-- same PKs, UNIQUE(draft_id) on schedules and global idempotency_key
-- uniqueness. Adds the scheduling principal, revision/approval proof,
-- credential generation/revocation, execution generation and the durable
-- attempt journal the scheduler claims, fences and finalizes against.
--
-- The current runner (db/migrate.ts) wraps each file in ONE transaction, so
-- this file is written to be transactional end to end: no CREATE INDEX
-- CONCURRENTLY, bounded by lock_timeout, preflight fails closed on corrupt
-- tenant references instead of silently reparenting them. Quiesce the
-- publishing worker for the upgrade (plan §2 "Existing data rollout").
SET LOCAL lock_timeout = '5s';
-- ---------------------------------------------------------------------------
-- Preflight: refuse to upgrade data that the tenant-composite FKs below would
-- reject anyway, with an operator-readable reason. Nothing is reparented.
-- ---------------------------------------------------------------------------
DO $$
DECLARE v_bad bigint;
BEGIN
SELECT count(*) INTO v_bad FROM scheduled_publications sp
WHERE NOT EXISTS (SELECT 1 FROM drafts d WHERE d.id = sp.draft_id AND d.workspace_id = sp.workspace_id)
OR NOT EXISTS (SELECT 1 FROM connections c WHERE c.id = sp.connection_id AND c.workspace_id = sp.workspace_id);
IF v_bad > 0 THEN
RAISE EXCEPTION '018 preflight: % scheduled_publications rows reference a draft/connection in another workspace', v_bad;
END IF;
SELECT count(*) INTO v_bad FROM publications p
WHERE NOT EXISTS (SELECT 1 FROM drafts d WHERE d.id = p.draft_id AND d.workspace_id = p.workspace_id)
OR NOT EXISTS (SELECT 1 FROM connections c WHERE c.id = p.connection_id AND c.workspace_id = p.workspace_id);
IF v_bad > 0 THEN
RAISE EXCEPTION '018 preflight: % publications rows reference a draft/connection in another workspace', v_bad;
END IF;
SELECT count(*) INTO v_bad FROM credentials cr
WHERE cr.connection_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM connections c WHERE c.id = cr.connection_id AND c.workspace_id = cr.workspace_id);
IF v_bad > 0 THEN
RAISE EXCEPTION '018 preflight: % credentials rows reference a connection in another workspace', v_bad;
END IF;
SELECT count(*) INTO v_bad FROM (
SELECT 1 FROM credentials WHERE kind = 'connector-oauth' AND connection_id IS NOT NULL
GROUP BY workspace_id, connection_id HAVING count(*) > 1
) dup;
IF v_bad > 0 THEN
RAISE EXCEPTION '018 preflight: % connections have more than one connector-oauth credential; reconcile before upgrading', v_bad;
END IF;
END $$;
-- ---------------------------------------------------------------------------
-- Credentials: generation + revocation. key_version is NOT a token
-- generation (APV1 sealing writes version 1 on every rotation).
-- ---------------------------------------------------------------------------
ALTER TABLE credentials
ADD COLUMN generation bigint NOT NULL DEFAULT 1 CHECK (generation > 0),
ADD COLUMN revoked_at timestamptz;
ALTER TABLE credentials ADD CONSTRAINT credentials_id_workspace_key UNIQUE (id, workspace_id);
-- One live connector credential per connection; readers stop using LIMIT 1.
CREATE UNIQUE INDEX credentials_connector_oauth_unique
ON credentials (workspace_id, connection_id) WHERE kind = 'connector-oauth';
-- generation is DB-owned: any secret-material or lifecycle change rotates it,
-- and callers cannot rewind it.
CREATE FUNCTION credentials_bump_generation() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.ciphertext IS DISTINCT FROM OLD.ciphertext
OR NEW.key_version IS DISTINCT FROM OLD.key_version
OR NEW.rotated_at IS DISTINCT FROM OLD.rotated_at
OR NEW.revoked_at IS DISTINCT FROM OLD.revoked_at THEN
NEW.generation := OLD.generation + 1;
ELSE
NEW.generation := OLD.generation;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_cred_generation BEFORE UPDATE ON credentials
FOR EACH ROW EXECUTE FUNCTION credentials_bump_generation();
-- Credentials are an execution-authorization input: changes bump the epoch
-- exactly like memberships (reuses bump_authz_epoch, 001/005).
CREATE TRIGGER trg_cred_bump_epoch
AFTER INSERT OR DELETE OR UPDATE OF ciphertext, key_version, rotated_at, revoked_at ON credentials
FOR EACH ROW EXECUTE FUNCTION bump_authz_epoch();
-- ---------------------------------------------------------------------------
-- Connections: tombstones keep journal evidence intact after removal.
-- ---------------------------------------------------------------------------
ALTER TABLE connections ADD COLUMN disconnected_at timestamptz;
CREATE TRIGGER trg_conn_disconnect_bump_epoch AFTER UPDATE OF disconnected_at ON connections
FOR EACH ROW WHEN (NEW.disconnected_at IS DISTINCT FROM OLD.disconnected_at)
EXECUTE FUNCTION bump_authz_epoch();
-- Group sharing gates userCanUseConnection, so it bumps the epoch too (008
-- only covered group_members).
CREATE TRIGGER trg_group_connection_bump_epoch AFTER INSERT OR UPDATE OR DELETE ON group_connections
FOR EACH ROW EXECUTE FUNCTION bump_authz_epoch();
-- role_permissions is global. Every workspace's Owner/Admin/... roles come
-- from it, so a change bumps all workspaces once per statement, locked in
-- UUID order to avoid lock-order inversions between concurrent bumps.
CREATE FUNCTION bump_all_authz_epochs() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
PERFORM 1 FROM workspaces ORDER BY id FOR UPDATE;
UPDATE workspaces SET authz_epoch = authz_epoch + 1;
RETURN NULL;
END $$;
CREATE TRIGGER trg_role_permissions_bump_epoch AFTER INSERT OR UPDATE OR DELETE ON role_permissions
FOR EACH STATEMENT EXECUTE FUNCTION bump_all_authz_epochs();
-- ---------------------------------------------------------------------------
-- Drafts: revision + approval proof + business attempt counter.
-- ---------------------------------------------------------------------------
ALTER TABLE drafts
ADD COLUMN revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
ADD COLUMN approved_revision bigint,
ADD COLUMN approved_hash text,
ADD COLUMN approved_at timestamptz,
ADD COLUMN review_reason text,
ADD COLUMN publication_attempt bigint NOT NULL DEFAULT 0 CHECK (publication_attempt >= 0);
-- drafts_id_workspace_key (id, workspace_id) already exists from 015.
-- ---------------------------------------------------------------------------
-- Scheduled publications: principal/fence snapshot + execution generation.
-- ---------------------------------------------------------------------------
ALTER TABLE scheduled_publications
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN scheduled_by_user_id uuid REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
ADD COLUMN required_permission text
CHECK (required_permission IN ('publication.schedule', 'publication.publish')),
ADD COLUMN authorized_epoch bigint,
ADD COLUMN approved_revision bigint,
ADD COLUMN post_hash text,
ADD COLUMN credential_id uuid,
ADD COLUMN credential_generation bigint,
ADD COLUMN generation bigint NOT NULL DEFAULT 1 CHECK (generation > 0),
ADD COLUMN attempt_no bigint NOT NULL DEFAULT 1 CHECK (attempt_no > 0),
ADD COLUMN execution_version smallint NOT NULL DEFAULT 0 CHECK (execution_version IN (0, 1)),
ADD COLUMN timezone text NOT NULL DEFAULT 'UTC',
ADD COLUMN local_publish_at timestamp,
ADD COLUMN tzdata_version text,
ADD COLUMN next_attempt_at timestamptz,
ADD COLUMN retry_count int NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
ADD COLUMN error_code text;
ALTER TABLE scheduled_publications DROP CONSTRAINT scheduled_publications_state_check;
ALTER TABLE scheduled_publications ADD CONSTRAINT scheduled_publications_state_check
CHECK (state IN ('pending', 'in_flight', 'done', 'failed', 'needs_reauthorization', 'cancelled'));
-- Legacy rows: timestamps from the draft, due time from publish_at, UTC zone.
UPDATE scheduled_publications sp
SET created_at = d.created_at, updated_at = d.updated_at, next_attempt_at = sp.publish_at
FROM drafts d
WHERE d.id = sp.draft_id AND d.workspace_id = sp.workspace_id;
-- Existing publications are authoritative: their schedule is done, draft published.
UPDATE scheduled_publications sp SET state = 'done', error = NULL, error_code = NULL
WHERE EXISTS (SELECT 1 FROM publications p
WHERE p.workspace_id = sp.workspace_id
AND (p.idempotency_key = sp.idempotency_key OR p.draft_id = sp.draft_id));
UPDATE drafts d SET state = 'published'
WHERE d.state <> 'published'
AND EXISTS (SELECT 1 FROM publications p WHERE p.workspace_id = d.workspace_id AND p.draft_id = d.id);
-- No recorded principal: never infer one from author/audit history.
UPDATE scheduled_publications SET state = 'needs_reauthorization', error_code = 'LEGACY_AUTHORIZATION_MISSING'
WHERE state = 'pending';
-- In flight without a publication: the send may have happened.
UPDATE scheduled_publications SET state = 'failed', error_code = 'PUBLISH_OUTCOME_UNKNOWN'
WHERE state = 'in_flight';
-- Upgraded (execution_version = 1) rows must carry every execution fence
-- while executable or done. Legacy rows (0) are readable, never executable:
-- every claim requires execution_version = 1.
ALTER TABLE scheduled_publications ADD CONSTRAINT scheduled_publications_fences_check CHECK (
execution_version = 0
OR state IN ('failed', 'needs_reauthorization', 'cancelled')
OR (scheduled_by_user_id IS NOT NULL AND required_permission IS NOT NULL
AND authorized_epoch IS NOT NULL AND approved_revision IS NOT NULL
AND post_hash IS NOT NULL AND credential_id IS NOT NULL
AND credential_generation IS NOT NULL AND next_attempt_at IS NOT NULL)
);
ALTER TABLE scheduled_publications
ADD CONSTRAINT scheduled_publications_id_workspace_key UNIQUE (id, workspace_id);
-- Tenant-composite replacements for 006's single-column FKs (same ON DELETE
-- semantics; journal tables below add NO ACTION guards).
ALTER TABLE scheduled_publications
DROP CONSTRAINT scheduled_publications_draft_id_fkey,
DROP CONSTRAINT scheduled_publications_connection_id_fkey,
ADD CONSTRAINT scheduled_publications_draft_ws_fkey FOREIGN KEY (draft_id, workspace_id)
REFERENCES drafts (id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT,
ADD CONSTRAINT scheduled_publications_connection_ws_fkey FOREIGN KEY (connection_id, workspace_id)
REFERENCES connections (id, workspace_id) ON DELETE NO ACTION ON UPDATE RESTRICT,
ADD CONSTRAINT scheduled_publications_credential_ws_fkey FOREIGN KEY (credential_id, workspace_id)
REFERENCES credentials (id, workspace_id) ON DELETE NO ACTION ON UPDATE RESTRICT;
ALTER TABLE publications
DROP CONSTRAINT publications_draft_id_fkey,
DROP CONSTRAINT publications_connection_id_fkey,
ADD CONSTRAINT publications_draft_ws_fkey FOREIGN KEY (draft_id, workspace_id)
REFERENCES drafts (id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT,
ADD CONSTRAINT publications_connection_ws_fkey FOREIGN KEY (connection_id, workspace_id)
REFERENCES connections (id, workspace_id) ON DELETE NO ACTION ON UPDATE RESTRICT;
-- Schedules: DB-owned updated_at; generation and execution_version only move forward.
CREATE FUNCTION scheduled_publications_guard() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.generation < OLD.generation THEN
RAISE EXCEPTION 'schedule generation cannot decrease' USING ERRCODE = '23514';
END IF;
IF NEW.execution_version < OLD.execution_version THEN
RAISE EXCEPTION 'schedule execution_version cannot decrease' USING ERRCODE = '23514';
END IF;
NEW.updated_at := now();
RETURN NEW;
END $$;
CREATE TRIGGER trg_scheduled_pub_guard BEFORE UPDATE ON scheduled_publications
FOR EACH ROW EXECUTE FUNCTION scheduled_publications_guard();
-- ---------------------------------------------------------------------------
-- Execution journal: one row per business attempt. Snapshot columns are
-- immutable; mutable columns carry ownership (owner_token/claim_epoch/lease)
-- and the dispatch marker.
-- ---------------------------------------------------------------------------
CREATE TABLE publishing_attempts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
schedule_id uuid NOT NULL,
draft_id uuid NOT NULL,
connection_id uuid NOT NULL,
attempt_no bigint NOT NULL CHECK (attempt_no > 0),
idempotency_key text NOT NULL CHECK (btrim(idempotency_key) <> ''),
schedule_generation bigint NOT NULL CHECK (schedule_generation > 0),
post_snapshot jsonb NOT NULL CHECK (jsonb_typeof(post_snapshot) = 'object'),
post_hash text NOT NULL CHECK (btrim(post_hash) <> ''),
scheduled_by_user_id uuid NOT NULL REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
required_permission text NOT NULL
CHECK (required_permission IN ('publication.schedule', 'publication.publish')),
approved_revision bigint NOT NULL CHECK (approved_revision > 0),
reviewer_user_id uuid NOT NULL REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
credential_id uuid NOT NULL,
credential_generation bigint NOT NULL CHECK (credential_generation > 0),
authorized_epoch bigint NOT NULL,
dispatch_count int NOT NULL DEFAULT 0 CHECK (dispatch_count >= 0 AND dispatch_count <= 5),
status text NOT NULL DEFAULT 'ready'
CHECK (status IN ('ready', 'in_progress', 'completed', 'failed', 'unknown', 'abandoned')),
claim_epoch bigint NOT NULL DEFAULT 0 CHECK (claim_epoch >= 0),
owner_token uuid,
lease_until timestamptz,
dispatch_started_at timestamptz,
provider_retry_deadline timestamptz,
error_code text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Ownership exists exactly while in progress.
CHECK ((status = 'in_progress') = (owner_token IS NOT NULL AND lease_until IS NOT NULL)),
CHECK (status = 'in_progress' OR (owner_token IS NULL AND lease_until IS NULL)),
-- A dispatch marker implies at least one counted dispatch.
CHECK (dispatch_started_at IS NULL OR dispatch_count > 0),
UNIQUE (workspace_id, id),
UNIQUE (workspace_id, draft_id, attempt_no),
UNIQUE (workspace_id, idempotency_key),
FOREIGN KEY (schedule_id, workspace_id) REFERENCES scheduled_publications (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT,
FOREIGN KEY (draft_id, workspace_id) REFERENCES drafts (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT,
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT,
FOREIGN KEY (credential_id, workspace_id) REFERENCES credentials (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT
);
-- Snapshot immutability, terminal statuses, and DB-owned claim epoch: every
-- new owner token advances claim_epoch by exactly one, so a deposed owner's
-- (owner_token, claim_epoch) pair can never match again.
CREATE FUNCTION publishing_attempts_guard() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF (NEW.schedule_id, NEW.draft_id, NEW.connection_id, NEW.attempt_no, NEW.idempotency_key,
NEW.schedule_generation, NEW.post_snapshot, NEW.post_hash, NEW.scheduled_by_user_id,
NEW.required_permission, NEW.approved_revision, NEW.reviewer_user_id,
NEW.credential_id, NEW.credential_generation, NEW.created_at)
IS DISTINCT FROM
(OLD.schedule_id, OLD.draft_id, OLD.connection_id, OLD.attempt_no, OLD.idempotency_key,
OLD.schedule_generation, OLD.post_snapshot, OLD.post_hash, OLD.scheduled_by_user_id,
OLD.required_permission, OLD.approved_revision, OLD.reviewer_user_id,
OLD.credential_id, OLD.credential_generation, OLD.created_at) THEN
RAISE EXCEPTION 'publishing attempt snapshot is immutable' USING ERRCODE = '23514';
END IF;
IF OLD.status IN ('completed', 'abandoned') AND NEW.status IS DISTINCT FROM OLD.status THEN
RAISE EXCEPTION 'publishing attempt % is terminal', OLD.status USING ERRCODE = '23514';
END IF;
IF NEW.dispatch_count < OLD.dispatch_count THEN
RAISE EXCEPTION 'dispatch_count cannot decrease' USING ERRCODE = '23514';
END IF;
IF NEW.provider_retry_deadline IS DISTINCT FROM OLD.provider_retry_deadline
AND OLD.provider_retry_deadline IS NOT NULL THEN
RAISE EXCEPTION 'provider_retry_deadline is fixed at first dispatch' USING ERRCODE = '23514';
END IF;
IF NEW.owner_token IS NOT NULL AND NEW.owner_token IS DISTINCT FROM OLD.owner_token THEN
NEW.claim_epoch := OLD.claim_epoch + 1;
ELSE
NEW.claim_epoch := OLD.claim_epoch;
END IF;
NEW.updated_at := now();
RETURN NEW;
END $$;
CREATE TRIGGER trg_publishing_attempt_ws_immutable BEFORE UPDATE ON publishing_attempts
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_publishing_attempt_guard BEFORE UPDATE ON publishing_attempts
FOR EACH ROW EXECUTE FUNCTION publishing_attempts_guard();
-- Durable provider evidence, separate from the attempt so it survives activity
-- timeouts and stale-owner completion refusal. Append-only.
CREATE TABLE publishing_receipts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
attempt_id uuid NOT NULL,
remote_id text NOT NULL CHECK (btrim(remote_id) <> '' AND length(remote_id) <= 512),
remote_url text CHECK (remote_url IS NULL OR length(remote_url) <= 2048),
published_at timestamptz,
provider_metadata jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(provider_metadata) = 'object'),
received_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, attempt_id),
FOREIGN KEY (attempt_id, workspace_id) REFERENCES publishing_attempts (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT
);
-- Receipts are never rewritten. Deletion is only the workspace purge cascade
-- (the parent workspace row is already gone when the cascade reaches here).
CREATE FUNCTION publishing_receipts_append_only() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
IF EXISTS (SELECT 1 FROM workspaces w WHERE w.id = OLD.workspace_id) THEN
RAISE EXCEPTION 'publishing receipts are append-only' USING ERRCODE = '23514';
END IF;
RETURN OLD;
END IF;
RAISE EXCEPTION 'publishing receipts are append-only' USING ERRCODE = '23514';
END $$;
CREATE TRIGGER trg_publishing_receipt_append_only BEFORE UPDATE OR DELETE ON publishing_receipts
FOR EACH ROW EXECUTE FUNCTION publishing_receipts_append_only();
-- Draft-creation request idempotency, committed atomically with the draft.
CREATE TABLE publishing_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT,
route text NOT NULL CHECK (btrim(route) <> ''),
request_key uuid NOT NULL,
request_hash text NOT NULL CHECK (btrim(request_hash) <> ''),
response jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, user_id, route, request_key)
);
CREATE TRIGGER trg_publishing_request_ws_immutable BEFORE UPDATE ON publishing_requests
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
-- ---------------------------------------------------------------------------
-- Draft fence: content changes rotate the revision and void approval; they
-- are refused while a dispatch is active or its outcome is unknown.
-- ---------------------------------------------------------------------------
CREATE FUNCTION drafts_revision_fence() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.publication_attempt < OLD.publication_attempt THEN
RAISE EXCEPTION 'publication_attempt cannot decrease' USING ERRCODE = '23514';
END IF;
IF NEW.post IS DISTINCT FROM OLD.post THEN
IF EXISTS (SELECT 1 FROM publishing_attempts a
WHERE a.workspace_id = OLD.workspace_id AND a.draft_id = OLD.id
AND (a.status = 'unknown'
OR (a.status = 'in_progress' AND a.dispatch_started_at IS NOT NULL))) THEN
RAISE EXCEPTION 'draft content is locked while a dispatch is unsettled' USING ERRCODE = '55000';
END IF;
NEW.revision := OLD.revision + 1;
NEW.approved_revision := NULL;
NEW.approved_hash := NULL;
NEW.approved_at := NULL;
NEW.reviewer_user_id := NULL;
ELSE
NEW.revision := OLD.revision;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_draft_revision_fence BEFORE UPDATE ON drafts
FOR EACH ROW EXECUTE FUNCTION drafts_revision_fence();
-- ---------------------------------------------------------------------------
-- RLS + grants for the new tenant tables (001/006/017 style).
-- ---------------------------------------------------------------------------
ALTER TABLE publishing_attempts ENABLE ROW LEVEL SECURITY;
ALTER TABLE publishing_receipts ENABLE ROW LEVEL SECURITY;
ALTER TABLE publishing_requests ENABLE ROW LEVEL SECURITY;
ALTER TABLE publishing_attempts FORCE ROW LEVEL SECURITY;
ALTER TABLE publishing_receipts FORCE ROW LEVEL SECURITY;
ALTER TABLE publishing_requests FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON publishing_attempts
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON publishing_receipts
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON publishing_requests
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
-- 003's default privileges grant all four verbs; narrow them explicitly:
-- the journal is never deleted by the app, receipts are append-only.
GRANT SELECT, INSERT, UPDATE ON publishing_attempts TO sp_app;
REVOKE DELETE ON publishing_attempts FROM sp_app;
GRANT SELECT, INSERT ON publishing_receipts TO sp_app;
REVOKE UPDATE, DELETE ON publishing_receipts FROM sp_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON publishing_requests TO sp_app;
-- ---------------------------------------------------------------------------
-- Indexes: keyset lists (no OFFSET), due/recovery discovery, FK coverage.
-- ---------------------------------------------------------------------------
CREATE INDEX drafts_ws_updated ON drafts (workspace_id, updated_at DESC, id DESC);
CREATE INDEX drafts_ws_state_updated ON drafts (workspace_id, state, updated_at DESC, id DESC);
CREATE INDEX scheduled_publications_ws_publish ON scheduled_publications (workspace_id, publish_at DESC, id DESC);
CREATE INDEX scheduled_publications_ws_conn_publish
ON scheduled_publications (workspace_id, connection_id, publish_at DESC, id DESC);
CREATE INDEX scheduled_publications_next_due ON scheduled_publications (next_attempt_at, id)
WHERE state = 'pending';
CREATE INDEX scheduled_publications_ws_credential ON scheduled_publications (workspace_id, credential_id)
WHERE credential_id IS NOT NULL;
CREATE INDEX publications_ws_published ON publications (workspace_id, published_at DESC, id DESC);
CREATE INDEX publications_ws_conn_published
ON publications (workspace_id, connection_id, published_at DESC, id DESC);
CREATE INDEX publishing_attempts_ws_schedule ON publishing_attempts (workspace_id, schedule_id, attempt_no DESC);
CREATE INDEX publishing_attempts_ws_connection ON publishing_attempts (workspace_id, connection_id);
CREATE INDEX publishing_attempts_ws_credential ON publishing_attempts (workspace_id, credential_id);
CREATE INDEX publishing_attempts_lease_recovery ON publishing_attempts (lease_until, id)
WHERE status = 'in_progress';
-- ---------------------------------------------------------------------------
-- Discovery (replaces 006). Identifiers/times only, bounded, fixed
-- search_path, PUBLIC revoked. Callers enter withTenant(workspace_id) and
-- re-verify key/generation ownership under RLS.
-- ---------------------------------------------------------------------------
DROP FUNCTION publishing_due_schedules();
CREATE FUNCTION publishing_due_schedules(p_limit integer DEFAULT 100)
RETURNS TABLE(workspace_id uuid, schedule_id uuid, idempotency_key text, generation bigint, publish_at timestamptz)
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
SELECT due.workspace_id, due.schedule_id, due.idempotency_key, due.generation, due.publish_at
FROM (
SELECT sp.workspace_id, sp.id AS schedule_id, sp.idempotency_key, sp.generation,
sp.publish_at, sp.next_attempt_at AS due_at
FROM scheduled_publications sp
WHERE sp.state = 'pending' AND sp.execution_version = 1 AND sp.next_attempt_at <= now()
UNION ALL
SELECT a.workspace_id, a.schedule_id, a.idempotency_key, a.schedule_generation,
sp.publish_at, a.lease_until AS due_at
FROM publishing_attempts a
JOIN scheduled_publications sp ON sp.id = a.schedule_id AND sp.workspace_id = a.workspace_id
WHERE a.status = 'in_progress' AND a.lease_until < now()
) due
ORDER BY due.due_at, due.schedule_id
LIMIT LEAST(GREATEST(COALESCE(p_limit, 100), 1), 100)
$$;
-- Attempt journal first (every executable key), then legacy schedules/publications.
CREATE OR REPLACE FUNCTION publishing_schedule_workspace(p_idempotency_key text) RETURNS uuid
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
SELECT COALESCE(
(SELECT a.workspace_id FROM publishing_attempts a WHERE a.idempotency_key = p_idempotency_key LIMIT 1),
(SELECT sp.workspace_id FROM scheduled_publications sp WHERE sp.idempotency_key = p_idempotency_key),
(SELECT p.workspace_id FROM publications p WHERE p.idempotency_key = p_idempotency_key)
)
$$;
REVOKE ALL ON FUNCTION publishing_due_schedules(integer) FROM PUBLIC;
REVOKE ALL ON FUNCTION publishing_schedule_workspace(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION publishing_due_schedules(integer), publishing_schedule_workspace(text) TO sp_app;