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/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
485
apps/api/migrations/018_publishing_execution.sql
Normal file
485
apps/api/migrations/018_publishing_execution.sql
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
-- 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue