-- 021: editable multi-platform streams columns — column definitions (WP3 §1.2, P0a). -- stream_columns only: the resolved-source / ingest / fan-out / budget tables -- (column_sources, feed_items, feed_item_columns, provider_budget_ledger) land -- in their own forward-only migrations so each slice stays independently -- reviewable. Additive: no existing table is touched. -- -- `spec` and `refresh_policy` are JSONB blobs validated app-side by -- @tactical-media-broadcast-hub/core (stream-columns.ts); invalid specs are rejected before -- insert and never stored. The CHECKs here are only coarse backstops — the -- authoritative typed validation lives in the app layer (017-style split). CREATE TABLE stream_columns ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, type text NOT NULL CHECK (type IN ('platform_feed','keyword','account_list','saved_search')), title text NOT NULL CHECK (char_length(title) <= 80), -- user-editable; app layer enforces too position integer NOT NULL DEFAULT 0 CHECK (position >= 0), -- dense rank; reorder rewrites the tail spec jsonb NOT NULL CHECK (jsonb_typeof(spec) = 'object'), refresh_policy jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(refresh_policy) = 'object'), created_by uuid NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), deleted_at timestamptz -- soft delete keeps feed_items FK-stable (§1.2) ); CREATE TRIGGER trg_stream_columns_ws_immutable BEFORE UPDATE ON stream_columns FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); -- Same fail-closed tenant isolation as every tenant table (001/006/017 style): -- no app.workspace_id => NULL => no rows. FORCE binds sp_app too. ALTER TABLE stream_columns ENABLE ROW LEVEL SECURITY; ALTER TABLE stream_columns FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON stream_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, UPDATE, DELETE ON stream_columns TO sp_app; -- Dense column order per workspace (UI chips, seeding, reorder). Partial on -- deleted_at IS NULL: soft-deleted columns keep their row (FK-stable items) -- without reserving their slot, so reorder/seeding only fights live rows. -- Named per design §1.2; lazy seeding relies on it for idempotence (§1.3). CREATE UNIQUE INDEX stream_columns_ws_order_uq ON stream_columns (workspace_id, position) WHERE deleted_at IS NULL; -- Workspace listing (GET /feeds/columns), newest-position reads served by the -- unique index above; this covers soft-deleted lookups and count queries in -- the partial-index style of workspace_invite_links_expiry (017). CREATE INDEX stream_columns_ws_idx ON stream_columns (workspace_id) WHERE deleted_at IS NULL;