-- 026: columns P1 — persistence + RBAC (WP3 §1.2/§3, §6 P1 row). -- -- (025 was taken by WP15 archive search; this file is the columns slice.) -- -- Three things land here, in 024's many-tables-one-file style: -- -- 1. The resolved-source / ingest / fan-out tables 021 deliberately left -- for their own slice: column_sources, feed_items, feed_item_columns. -- (provider_budget_ledger is P2b/P5 territory — no ledger caller exists -- yet, so the table is not created here.) -- 2. host_budgets — the shared per-host egress budget table owned by -- @tactical-media-broadcast-hub/egress (its migrations/001_host_budgets.sql, applied -- verbatim with credit; the egress package never migrates at runtime and -- expects the application to apply and ledger it). The columns scheduler -- is the first app-side caller of HostBudgets that does not go through -- the whole egressFetch client, so the table must exist before P1's -- poll loop draws from it. IF NOT EXISTS keeps this idempotent if a -- later egress-integration migration also carries it. -- 3. The `streams.columns.manage` RBAC seed (design §1.4/§0.5): write -- permission on columns, granted to Owner/Admin/Editor by default. -- Reads stay on `draft.view`. -- -- RLS / NULLIF / reject_workspace_change / sp_app grants follow the -- 001/014/021/024 conventions: no app.workspace_id => NULL => no rows; FORCE -- binds sp_app too. `spec`/`refresh_policy` stay validated app-side by -- @tactical-media-broadcast-hub/core (021 note); the CHECKs here are coarse backstops. -- --------------------------------------------------------------------------- -- column_sources — what the scheduler actually polls for a column (§1.2) -- --------------------------------------------------------------------------- CREATE TABLE column_sources ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT, column_id uuid NOT NULL REFERENCES stream_columns(id) ON DELETE CASCADE ON UPDATE RESTRICT, -- Identity of the thing polled (manifest id of a connector). provider text NOT NULL CHECK (btrim(provider) <> '' AND length(provider) <= 64), -- NULL for keyless sources (YouTube RSS, public Bluesky/Mastodon) — those -- draw from the per-host budget only (§1.2 "Keyless sources"). connection_id uuid, source_kind text NOT NULL CHECK (source_kind IN ('home','mentions','search','author')), -- Platform-adapted query (post-adaptation, what gets sent); NULL for -- home/mentions/author sources. query text, -- Canonical account ref for source_kind='author': '{provider}:{handle@host}'. actor_ref text, state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending','ready','stale','error','unsupported','rate_limited','budget_paused','disabled')), last_error text, last_polled_at timestamptz, next_poll_at timestamptz, consecutive_failures integer NOT NULL DEFAULT 0 CHECK (consecutive_failures >= 0), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (workspace_id, id), FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id) ON DELETE NO ACTION ON UPDATE RESTRICT, -- One source row per (column, polled identity); NULLs equal NULLs so a -- keyless search source dedupes against itself (024 NULLS NOT DISTINCT -- style). The SAME identity in two different columns is intentional (§3.3: -- following the same account in two columns costs one poll) — the due -- source scan collapses those rows into one poll and fans items out to -- every column sharing the identity. UNIQUE NULLS NOT DISTINCT (workspace_id, column_id, provider, source_kind, connection_id, query, actor_ref) ); CREATE TRIGGER trg_column_sources_ws_immutable BEFORE UPDATE ON column_sources FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); ALTER TABLE column_sources ENABLE ROW LEVEL SECURITY; ALTER TABLE column_sources FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON column_sources USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); GRANT SELECT, INSERT, UPDATE, DELETE ON column_sources TO sp_app; -- Due-source scan (§3.4): workspace + next_poll_at with the disabled rows -- partial-indexed away. Design §1.2 names this column_sources_due_idx on -- (next_poll_at); workspace leads here because every scan is tenant-scoped -- under RLS and the composite keeps each workspace's due window contiguous. CREATE INDEX column_sources_due_idx ON column_sources (workspace_id, next_poll_at) WHERE state <> 'disabled'; -- Column fan-out listing (ColumnView.sources) and per-column refresh. CREATE INDEX column_sources_column_idx ON column_sources (column_id); -- Connection → sources (the degenerate platform_feed lookup in the scheduler -- and the /feeds per-connection store fallback). CREATE INDEX column_sources_connection_idx ON column_sources (connection_id) WHERE connection_id IS NOT NULL; -- --------------------------------------------------------------------------- -- feed_items — ingested items (§1.2): persisted depth for keyset pagination -- and cross-column dedup; Redis stays the hot read-through cache in front. -- --------------------------------------------------------------------------- CREATE TABLE feed_items ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT, -- sha256(workspace_id | provider | canonical_remote_id) — see §3.3; the -- canonicalization rule is app-side, the key is opaque to the DB. dedup_key text NOT NULL CHECK (dedup_key <> ''), provider text NOT NULL CHECK (btrim(provider) <> '' AND length(provider) <= 64), remote_id text NOT NULL CHECK (remote_id <> ''), -- Connector-reported FeedItem.id; first writer wins, kept stable so a -- cursor minted from the hot cache still tie-breaks identically when the -- same page is later served from the store. item_id text NOT NULL CHECK (item_id <> ''), author_handle text NOT NULL CHECK (author_handle <> ''), -- Canonical '{provider}:{handle@host}' when resolvable (P0c feedAuthorRef). author_ref text, -- Provider permalink; absolute credential-free http(s) only (P0c parseSafeHttpUrl). url text CHECK (url IS NULL OR url ~ '^https?://'), -- Provider timestamp — the deterministic keyset sort key. created_at_remote timestamptz NOT NULL, fetched_at timestamptz NOT NULL DEFAULT now(), -- safe-content typed rich-text AST. NEVER raw HTML (§5.1) — the app layer -- produces this through normalizeFeedItem; the CHECK only backstops shape. content jsonb NOT NULL CHECK (jsonb_typeof(content) = 'object'), -- Derived flat text (astToPlainText) for saved-search filtering/previews. plain_text text NOT NULL, -- [{kind:'image'|'video', proxyUrl, ...}] — proxy-minted only (§5.3). media jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(media) = 'array'), -- Finite numbers only (validated at ingest). metrics jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metrics) = 'object'), source_id uuid NOT NULL, -- ToS expiry (YouTube Data-API items: fetched_at + 30d); sweeper is P2c. expires_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE (workspace_id, dedup_key), UNIQUE (id, workspace_id), FOREIGN KEY (source_id, workspace_id) REFERENCES column_sources (id, workspace_id) ON DELETE NO ACTION ON UPDATE RESTRICT ); CREATE TRIGGER trg_feed_items_ws_immutable BEFORE UPDATE ON feed_items FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); ALTER TABLE feed_items ENABLE ROW LEVEL SECURITY; ALTER TABLE feed_items FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON feed_items USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); GRANT SELECT, INSERT, UPDATE, DELETE ON feed_items TO sp_app; -- Keyset serving: GET /feeds/columns/:id/items pages (created_at_remote DESC, -- dedup_key DESC) over this index (§1.4 "Cursor rule"). CREATE INDEX feed_items_ws_time_idx ON feed_items (workspace_id, created_at_remote DESC, dedup_key DESC); -- Store backfill for /feeds per connection and per-source item listing. CREATE INDEX feed_items_source_idx ON feed_items (source_id, created_at_remote DESC); -- Expiry sweeper (P2c) pre-indexed here so the later slice adds no DDL. CREATE INDEX feed_items_expiry_idx ON feed_items (expires_at) WHERE expires_at IS NOT NULL; -- --------------------------------------------------------------------------- -- feed_item_columns — which columns show an item (§1.2 table 4). An item -- ingested for a source identity is visible to every column sharing that -- identity; rows are written by poll-time fan-out with ON CONFLICT DO NOTHING. -- --------------------------------------------------------------------------- -- stream_columns (021) has only its surrogate PK; migrations < 025 are frozen, -- so the tenant-composite keys the FKs below need are added here — additive -- unique indexes on supersets of existing keys, so no uniqueness changes. CREATE UNIQUE INDEX IF NOT EXISTS stream_columns_id_workspace_key ON stream_columns (id, workspace_id); CREATE TABLE feed_item_columns ( workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT, item_id uuid NOT NULL, column_id uuid NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (item_id, column_id), FOREIGN KEY (item_id, workspace_id) REFERENCES feed_items (id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT, FOREIGN KEY (column_id, workspace_id) REFERENCES stream_columns (id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT ); CREATE TRIGGER trg_feed_item_columns_ws_immutable BEFORE UPDATE ON feed_item_columns FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); ALTER TABLE feed_item_columns ENABLE ROW LEVEL SECURITY; ALTER TABLE feed_item_columns FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON feed_item_columns USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); GRANT SELECT, INSERT, DELETE ON feed_item_columns TO sp_app; -- Serving index: the items endpoint joins column_id → item_id before the -- keyset filter; the PK alone would force an item_id-ordered probe. CREATE INDEX feed_item_columns_column_idx ON feed_item_columns (column_id, item_id); -- --------------------------------------------------------------------------- -- host_budgets — applied verbatim from @tactical-media-broadcast-hub/egress -- migrations/001_host_budgets.sql (WP7/WP8). Infrastructure state shared by -- all egress callers; deliberately NOT tenant/RLS scoped (one IDNA lowercase -- host:port key across callers and host classes — the whole point is that -- monitor, feeds and any other caller of the same host share one bucket). -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS host_budgets ( host text PRIMARY KEY CHECK (host <> '' AND host = lower(host)), capacity double precision NOT NULL CHECK (capacity >= 1 AND capacity < 'Infinity'::double precision), -- Tokens per fixed 10-second interval (fractional tokens support continuous refill). refill_per_interval double precision NOT NULL CHECK (refill_per_interval > 0 AND refill_per_interval < 'Infinity'::double precision), tokens double precision NOT NULL CHECK (tokens >= 0 AND tokens <= capacity), -- A future timestamp parks the bucket: no refill/debit until this deadline. updated_at timestamptz NOT NULL DEFAULT clock_timestamp() CHECK (isfinite(updated_at)) ); COMMENT ON TABLE host_budgets IS 'Infrastructure egress budgets; one IDNA lowercase host:effective-port key across callers and host classes. Retain while host is in use.'; GRANT SELECT, INSERT, UPDATE ON host_budgets TO sp_app; -- --------------------------------------------------------------------------- -- RBAC seed: streams.columns.manage (design §1.4 — granted to Owner/Admin/ -- Editor by default; reads stay on draft.view). 002's unnest + ON CONFLICT -- pattern so the seed is re-runnable and never fights a later role edit. -- --------------------------------------------------------------------------- INSERT INTO role_permissions (role, permission) SELECT 'Owner', 'streams.columns.manage' UNION ALL SELECT 'Admin', 'streams.columns.manage' UNION ALL SELECT 'Editor', 'streams.columns.manage' ON CONFLICT DO NOTHING;