Merge remote-tracking branch 'upstream/main' into merge-gramsrv-2965f5d

This commit is contained in:
onysd 2026-07-20 23:43:51 +03:00
commit ebb0be38d9
355 changed files with 44640 additions and 2320 deletions

View file

@ -5554,7 +5554,7 @@ SET row_security = off;
-- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: -
--
INSERT INTO public.users (id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id) VALUES (777000, 6599886787491911851, '42777', 'Telegram', '', 'telegram', '', '2026-06-19 13:35:51.253491+00', '2026-06-19 13:35:51.253491+00', true, true, '', 0, 0, false, 0, NULL, 0, 0, false, 0, 0, false, 0, 0);
INSERT INTO public.users (id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id) VALUES (777000, 6599886787491911851, '42777', 'Telesrv', '', 'telesrv', '', '2026-06-19 13:35:51.253491+00', '2026-06-19 13:35:51.253491+00', true, true, '', 0, 0, false, 0, NULL, 0, 0, false, 0, 0, false, 0, 0);
INSERT INTO public.users (id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id) VALUES (93372553, 7421896403922962293, '', 'BotFather', '', 'BotFather', '', '2026-06-19 13:35:52.688367+00', '2026-06-19 13:35:52.688367+00', true, false, '', 0, 0, true, 1, NULL, 0, 0, false, 0, 0, false, 0, 0);

View file

@ -0,0 +1,72 @@
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM public.star_gift_collectible_models
WHERE rarity_kind <> 'permille' OR crafted
) THEN
RAISE EXCEPTION 'cannot downgrade while categorical/crafted collectible models exist';
END IF;
END;
$$;
DROP INDEX IF EXISTS public.star_gift_catalog_revisions_official_source_idx;
ALTER TABLE public.star_gift_collectible_backdrops
DROP CONSTRAINT star_gift_collectible_backdrop_rarity_check,
ALTER COLUMN rarity_permille SET NOT NULL,
DROP COLUMN rarity_kind,
ADD CONSTRAINT star_gift_collectible_backdrop_rarity_check CHECK (rarity_permille BETWEEN 1 AND 1000);
ALTER TABLE public.star_gift_collectible_patterns
DROP CONSTRAINT star_gift_collectible_pattern_official_document_check,
DROP CONSTRAINT star_gift_collectible_pattern_rarity_check,
ALTER COLUMN rarity_permille SET NOT NULL,
DROP COLUMN official_document_id,
DROP COLUMN rarity_kind,
ADD CONSTRAINT star_gift_collectible_pattern_rarity_check CHECK (rarity_permille BETWEEN 1 AND 1000);
ALTER TABLE public.star_gift_collectible_models
DROP CONSTRAINT star_gift_collectible_model_official_document_check,
DROP CONSTRAINT star_gift_collectible_model_rarity_check,
ALTER COLUMN rarity_permille SET NOT NULL,
DROP COLUMN official_document_id,
DROP COLUMN crafted,
DROP COLUMN rarity_kind,
ADD CONSTRAINT star_gift_collectible_model_rarity_check CHECK (rarity_permille BETWEEN 1 AND 1000);
ALTER TABLE public.star_gift_collectible_revisions
DROP CONSTRAINT star_gift_collectible_official_source_check,
DROP COLUMN source_manifest_sha256,
DROP COLUMN official_gift_id;
ALTER TABLE public.star_gift_catalog_revisions
DROP CONSTRAINT star_gift_catalog_official_source_check,
DROP COLUMN official_source,
DROP COLUMN source_manifest_sha256,
DROP COLUMN official_gift_id;
CREATE OR REPLACE FUNCTION public.telesrv_guard_collectible_revision() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
IF OLD.status = 'published' THEN
RAISE EXCEPTION 'published collectible revision is immutable';
END IF;
RETURN OLD;
END IF;
IF OLD.status = 'published' THEN
IF NEW.gift_id <> OLD.gift_id OR NEW.revision <> OLD.revision OR
NEW.upgrade_stars <> OLD.upgrade_stars OR NEW.supply_total <> OLD.supply_total OR
NEW.slug_prefix <> OLD.slug_prefix OR NEW.status <> OLD.status OR
NEW.created_by <> OLD.created_by OR NEW.command_id <> OLD.command_id OR
NEW.created_at <> OLD.created_at OR NEW.published_at <> OLD.published_at THEN
RAISE EXCEPTION 'published collectible revision is immutable';
END IF;
IF NEW.issued <> OLD.issued + 1 THEN
RAISE EXCEPTION 'published collectible issuance must advance exactly once';
END IF;
END IF;
RETURN NEW;
END;
$$;

View file

@ -0,0 +1,88 @@
-- Preserve the complete Layer 228 official collectible attribute shape. Display rarity is
-- distinct from regular-upgrade selection eligibility, and official provenance is recorded
-- on both immutable revisions created by one import command.
ALTER TABLE public.star_gift_catalog_revisions
ADD COLUMN official_gift_id bigint,
ADD COLUMN source_manifest_sha256 bytea,
ADD COLUMN official_source jsonb,
ADD CONSTRAINT star_gift_catalog_official_source_check CHECK (
(official_gift_id IS NULL AND source_manifest_sha256 IS NULL AND official_source IS NULL) OR
(official_gift_id > 0 AND source_manifest_sha256 IS NOT NULL AND official_source IS NOT NULL AND
octet_length(source_manifest_sha256) = 32 AND jsonb_typeof(official_source) = 'object')
);
ALTER TABLE public.star_gift_collectible_revisions
ADD COLUMN official_gift_id bigint,
ADD COLUMN source_manifest_sha256 bytea,
ADD CONSTRAINT star_gift_collectible_official_source_check CHECK (
(official_gift_id IS NULL AND source_manifest_sha256 IS NULL) OR
(official_gift_id > 0 AND source_manifest_sha256 IS NOT NULL AND octet_length(source_manifest_sha256) = 32)
);
ALTER TABLE public.star_gift_collectible_models
DROP CONSTRAINT star_gift_collectible_model_rarity_check,
ALTER COLUMN rarity_permille DROP NOT NULL,
ADD COLUMN rarity_kind text DEFAULT 'permille' NOT NULL,
ADD COLUMN crafted boolean DEFAULT false NOT NULL,
ADD COLUMN official_document_id bigint,
ADD CONSTRAINT star_gift_collectible_model_rarity_check CHECK (
rarity_kind IN ('permille', 'uncommon', 'rare', 'epic', 'legendary') AND
((rarity_kind = 'permille' AND rarity_permille BETWEEN 1 AND 1000 AND NOT crafted) OR
(rarity_kind <> 'permille' AND rarity_permille IS NULL AND crafted))
),
ADD CONSTRAINT star_gift_collectible_model_official_document_check CHECK (
official_document_id IS NULL OR official_document_id > 0
);
ALTER TABLE public.star_gift_collectible_patterns
DROP CONSTRAINT star_gift_collectible_pattern_rarity_check,
ALTER COLUMN rarity_permille DROP NOT NULL,
ADD COLUMN rarity_kind text DEFAULT 'permille' NOT NULL,
ADD COLUMN official_document_id bigint,
ADD CONSTRAINT star_gift_collectible_pattern_rarity_check CHECK (
rarity_kind = 'permille' AND rarity_permille BETWEEN 1 AND 1000
),
ADD CONSTRAINT star_gift_collectible_pattern_official_document_check CHECK (
official_document_id IS NULL OR official_document_id > 0
);
ALTER TABLE public.star_gift_collectible_backdrops
DROP CONSTRAINT star_gift_collectible_backdrop_rarity_check,
ALTER COLUMN rarity_permille DROP NOT NULL,
ADD COLUMN rarity_kind text DEFAULT 'permille' NOT NULL,
ADD CONSTRAINT star_gift_collectible_backdrop_rarity_check CHECK (
rarity_kind = 'permille' AND rarity_permille BETWEEN 1 AND 1000
);
CREATE INDEX star_gift_catalog_revisions_official_source_idx
ON public.star_gift_catalog_revisions(official_gift_id, id DESC)
WHERE official_gift_id IS NOT NULL;
CREATE OR REPLACE FUNCTION public.telesrv_guard_collectible_revision() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
IF OLD.status = 'published' THEN
RAISE EXCEPTION 'published collectible revision is immutable';
END IF;
RETURN OLD;
END IF;
IF OLD.status = 'published' THEN
IF NEW.gift_id <> OLD.gift_id OR NEW.revision <> OLD.revision OR
NEW.upgrade_stars <> OLD.upgrade_stars OR NEW.supply_total <> OLD.supply_total OR
NEW.slug_prefix <> OLD.slug_prefix OR NEW.status <> OLD.status OR
NEW.created_by <> OLD.created_by OR NEW.command_id <> OLD.command_id OR
NEW.created_at <> OLD.created_at OR NEW.published_at <> OLD.published_at OR
NEW.official_gift_id IS DISTINCT FROM OLD.official_gift_id OR
NEW.source_manifest_sha256 IS DISTINCT FROM OLD.source_manifest_sha256 THEN
RAISE EXCEPTION 'published collectible revision is immutable';
END IF;
IF NEW.issued <> OLD.issued + 1 THEN
RAISE EXCEPTION 'published collectible issuance must advance exactly once';
END IF;
END IF;
RETURN NEW;
END;
$$;

View file

@ -0,0 +1,34 @@
DROP TABLE IF EXISTS public.star_gift_user_purchases;
ALTER TABLE public.star_gift_catalog_revisions
DROP CONSTRAINT IF EXISTS star_gift_catalog_revision_background_check,
DROP CONSTRAINT IF EXISTS star_gift_catalog_revision_auction_check,
DROP CONSTRAINT IF EXISTS star_gift_catalog_revision_released_by_check,
DROP CONSTRAINT IF EXISTS star_gift_catalog_revision_supply_check,
DROP COLUMN IF EXISTS background_text_color,
DROP COLUMN IF EXISTS background_edge_color,
DROP COLUMN IF EXISTS background_center_color,
DROP COLUMN IF EXISTS upgrade_variants,
DROP COLUMN IF EXISTS auction_start_date,
DROP COLUMN IF EXISTS gifts_per_round,
DROP COLUMN IF EXISTS auction_slug,
DROP COLUMN IF EXISTS locked_until_date,
DROP COLUMN IF EXISTS per_user_total,
DROP COLUMN IF EXISTS released_by_peer_id,
DROP COLUMN IF EXISTS released_by_peer_type,
DROP COLUMN IF EXISTS availability_total,
DROP COLUMN IF EXISTS auction,
DROP COLUMN IF EXISTS peer_color_available,
DROP COLUMN IF EXISTS limited_per_user,
DROP COLUMN IF EXISTS require_premium,
DROP COLUMN IF EXISTS birthday,
DROP COLUMN IF EXISTS sold_out,
DROP COLUMN IF EXISTS limited;
ALTER TABLE public.star_gift_catalog
DROP CONSTRAINT IF EXISTS star_gift_catalog_inventory_check,
DROP COLUMN IF EXISTS last_sale_date,
DROP COLUMN IF EXISTS first_sale_date,
DROP COLUMN IF EXISTS availability_resale,
DROP COLUMN IF EXISTS resell_min_stars,
DROP COLUMN IF EXISTS availability_remains;

View file

@ -0,0 +1,68 @@
-- Preserve the complete Layer 228 regular StarGift shape. Release facts are immutable
-- catalog-revision data; inventory and sale timestamps belong to the mutable catalog
-- aggregate. Per-user ownership limits are enforced by the transaction boundary rather
-- than reconstructed from peer_star_gifts after the fact.
ALTER TABLE public.star_gift_catalog
ADD COLUMN availability_remains integer DEFAULT 0 NOT NULL,
ADD COLUMN availability_resale bigint DEFAULT 0 NOT NULL,
ADD COLUMN resell_min_stars bigint DEFAULT 0 NOT NULL,
ADD COLUMN first_sale_date integer DEFAULT 0 NOT NULL,
ADD COLUMN last_sale_date integer DEFAULT 0 NOT NULL,
ADD CONSTRAINT star_gift_catalog_inventory_check CHECK (
availability_remains >= 0 AND availability_resale >= 0 AND resell_min_stars >= 0 AND
first_sale_date >= 0 AND last_sale_date >= 0 AND
(last_sale_date = 0 OR first_sale_date > 0) AND
(first_sale_date = 0 OR last_sale_date = 0 OR last_sale_date >= first_sale_date)
);
ALTER TABLE public.star_gift_catalog_revisions
ADD COLUMN limited boolean DEFAULT false NOT NULL,
ADD COLUMN sold_out boolean DEFAULT false NOT NULL,
ADD COLUMN birthday boolean DEFAULT false NOT NULL,
ADD COLUMN require_premium boolean DEFAULT false NOT NULL,
ADD COLUMN limited_per_user boolean DEFAULT false NOT NULL,
ADD COLUMN peer_color_available boolean DEFAULT false NOT NULL,
ADD COLUMN auction boolean DEFAULT false NOT NULL,
ADD COLUMN availability_total integer DEFAULT 0 NOT NULL,
ADD COLUMN released_by_peer_type text,
ADD COLUMN released_by_peer_id bigint,
ADD COLUMN per_user_total integer DEFAULT 0 NOT NULL,
ADD COLUMN locked_until_date integer DEFAULT 0 NOT NULL,
ADD COLUMN auction_slug text DEFAULT '' NOT NULL,
ADD COLUMN gifts_per_round integer DEFAULT 0 NOT NULL,
ADD COLUMN auction_start_date integer DEFAULT 0 NOT NULL,
ADD COLUMN upgrade_variants integer DEFAULT 0 NOT NULL,
ADD COLUMN background_center_color integer,
ADD COLUMN background_edge_color integer,
ADD COLUMN background_text_color integer,
ADD CONSTRAINT star_gift_catalog_revision_supply_check CHECK (
availability_total >= 0 AND
per_user_total >= 0 AND locked_until_date >= 0 AND upgrade_variants >= 0 AND
((limited AND availability_total > 0) OR (NOT limited AND availability_total = 0)) AND
(NOT sold_out OR limited) AND
((limited_per_user AND per_user_total > 0) OR (NOT limited_per_user AND per_user_total = 0))
),
ADD CONSTRAINT star_gift_catalog_revision_released_by_check CHECK (
(released_by_peer_type IS NULL AND released_by_peer_id IS NULL) OR
(released_by_peer_type IN ('user', 'chat', 'channel') AND released_by_peer_id > 0)
),
ADD CONSTRAINT star_gift_catalog_revision_auction_check CHECK (
(auction AND limited AND auction_slug <> '' AND gifts_per_round > 0 AND auction_start_date > 0) OR
(NOT auction AND auction_slug = '' AND gifts_per_round = 0 AND auction_start_date = 0)
),
ADD CONSTRAINT star_gift_catalog_revision_background_check CHECK (
(background_center_color IS NULL AND background_edge_color IS NULL AND background_text_color IS NULL) OR
(background_center_color BETWEEN 0 AND 16777215 AND
background_edge_color BETWEEN 0 AND 16777215 AND
background_text_color BETWEEN 0 AND 16777215)
);
CREATE TABLE public.star_gift_user_purchases (
user_id bigint NOT NULL,
gift_id bigint NOT NULL REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT,
purchased_count integer DEFAULT 0 NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT star_gift_user_purchases_pkey PRIMARY KEY (user_id, gift_id),
CONSTRAINT star_gift_user_purchases_count_check CHECK (user_id > 0 AND purchased_count >= 0)
);

View file

@ -0,0 +1,76 @@
DROP TRIGGER IF EXISTS peer_unique_star_gift_owner_guard ON public.peer_star_gifts;
DROP TRIGGER IF EXISTS unique_star_gift_owner_guard ON public.unique_star_gifts;
DROP FUNCTION IF EXISTS public.telesrv_check_unique_star_gift_owner();
DROP TRIGGER IF EXISTS star_gift_listing_guard ON public.star_gift_listings;
DROP FUNCTION IF EXISTS public.telesrv_guard_star_gift_listing();
DROP TABLE IF EXISTS public.ton_transactions;
DROP TABLE IF EXISTS public.ton_balances;
DROP TABLE IF EXISTS public.star_gift_auction_acquired;
DROP TABLE IF EXISTS public.star_gift_auction_bid_payments;
DROP TABLE IF EXISTS public.star_gift_auction_bids;
DROP TABLE IF EXISTS public.star_gift_auctions;
DROP TABLE IF EXISTS public.star_gift_withdrawal_requests;
DROP TABLE IF EXISTS public.star_gift_notification_settings;
DROP TABLE IF EXISTS public.star_gift_craft_commands;
DROP TABLE IF EXISTS public.star_gift_transfer_commands;
DROP TABLE IF EXISTS public.star_gift_purchase_commands;
DROP TABLE IF EXISTS public.star_gift_drop_details_commands;
DROP TABLE IF EXISTS public.star_gift_prepaid_upgrade_commands;
DROP TABLE IF EXISTS public.star_gift_offers;
DROP TABLE IF EXISTS public.star_gift_sales;
DROP TABLE IF EXISTS public.star_gift_listings;
ALTER TABLE public.unique_star_gifts
DROP CONSTRAINT IF EXISTS unique_star_gift_value_check,
DROP CONSTRAINT IF EXISTS unique_star_gift_original_owner_check,
DROP CONSTRAINT IF EXISTS unique_star_gift_host_peer_check,
DROP CONSTRAINT IF EXISTS unique_star_gift_theme_peer_check,
DROP CONSTRAINT IF EXISTS unique_star_gift_released_by_check,
DROP CONSTRAINT IF EXISTS unique_star_gift_owner_check,
DROP COLUMN IF EXISTS last_sale_amount,
DROP COLUMN IF EXISTS last_sale_currency,
DROP COLUMN IF EXISTS last_sale_date,
DROP COLUMN IF EXISTS craft_chance_permille,
DROP COLUMN IF EXISTS offer_min_stars,
DROP COLUMN IF EXISTS host_peer_id,
DROP COLUMN IF EXISTS host_peer_type,
DROP COLUMN IF EXISTS theme_peer_id,
DROP COLUMN IF EXISTS theme_peer_type,
DROP COLUMN IF EXISTS value_usd_amount,
DROP COLUMN IF EXISTS value_currency,
DROP COLUMN IF EXISTS value_amount,
DROP COLUMN IF EXISTS released_by_peer_id,
DROP COLUMN IF EXISTS released_by_peer_type,
DROP COLUMN IF EXISTS gift_address,
DROP COLUMN IF EXISTS owner_address,
DROP COLUMN IF EXISTS owner_name,
DROP COLUMN IF EXISTS crafted,
DROP COLUMN IF EXISTS original_owner_peer_id,
DROP COLUMN IF EXISTS original_owner_peer_type,
DROP COLUMN IF EXISTS burned,
DROP COLUMN IF EXISTS theme_available,
DROP COLUMN IF EXISTS resale_ton_only,
DROP COLUMN IF EXISTS require_premium;
UPDATE public.unique_star_gifts u
SET owner_peer_type=p.owner_peer_type, owner_peer_id=p.owner_peer_id
FROM public.peer_star_gifts p
WHERE p.unique_gift_id=u.id AND (u.owner_peer_type IS NULL OR u.owner_peer_id IS NULL);
ALTER TABLE public.unique_star_gifts
ALTER COLUMN owner_peer_type SET NOT NULL,
ALTER COLUMN owner_peer_id SET NOT NULL,
ADD CONSTRAINT unique_star_gift_owner_check CHECK (owner_peer_type IN ('user','channel') AND owner_peer_id>0);
ALTER TABLE public.peer_star_gifts
DROP COLUMN IF EXISTS prepaid_upgrade_hash,
DROP COLUMN IF EXISTS gift_num,
DROP CONSTRAINT IF EXISTS peer_star_gifts_lifecycle_check,
DROP COLUMN IF EXISTS can_craft_at,
DROP COLUMN IF EXISTS drop_original_details_stars,
DROP COLUMN IF EXISTS can_resell_at,
DROP COLUMN IF EXISTS can_transfer_at,
DROP COLUMN IF EXISTS can_export_at,
DROP COLUMN IF EXISTS transfer_stars,
DROP COLUMN IF EXISTS lifecycle_status,
ADD CONSTRAINT peer_star_gifts_terminal_state_check CHECK (NOT converted OR unique_gift_id IS NULL);

View file

@ -0,0 +1,425 @@
-- Complete collectible Star Gift lifecycle: ownership state, transfer/resale, purchase
-- offers, crafting, auctions, notification preferences and the explicit TON boundary.
ALTER TABLE public.peer_star_gifts
DROP CONSTRAINT IF EXISTS peer_star_gifts_terminal_state_check,
ADD COLUMN lifecycle_status text DEFAULT 'active' NOT NULL,
ADD COLUMN transfer_stars bigint DEFAULT 0 NOT NULL,
ADD COLUMN prepaid_upgrade_hash text DEFAULT '' NOT NULL,
ADD COLUMN gift_num integer DEFAULT 0 NOT NULL,
ADD COLUMN can_export_at integer DEFAULT 0 NOT NULL,
ADD COLUMN can_transfer_at integer DEFAULT 0 NOT NULL,
ADD COLUMN can_resell_at integer DEFAULT 0 NOT NULL,
ADD COLUMN drop_original_details_stars bigint DEFAULT 0 NOT NULL,
ADD COLUMN can_craft_at integer DEFAULT 0 NOT NULL;
UPDATE public.peer_star_gifts SET lifecycle_status='converted' WHERE converted;
ALTER TABLE public.peer_star_gifts
ADD CONSTRAINT peer_star_gifts_lifecycle_check CHECK (
lifecycle_status IN ('active', 'converted', 'burned', 'exported') AND
transfer_stars >= 0 AND gift_num >= 0 AND can_export_at >= 0 AND can_transfer_at >= 0 AND
can_resell_at >= 0 AND drop_original_details_stars >= 0 AND can_craft_at >= 0 AND
((lifecycle_status='converted' AND converted AND unique_gift_id IS NULL) OR
(lifecycle_status='active' AND NOT converted) OR
(lifecycle_status IN ('burned','exported') AND NOT converted AND unique_gift_id IS NOT NULL))
);
CREATE UNIQUE INDEX peer_star_gifts_prepaid_upgrade_hash_uniq
ON public.peer_star_gifts(prepaid_upgrade_hash) WHERE prepaid_upgrade_hash<>'';
ALTER TABLE public.unique_star_gifts
ALTER COLUMN owner_peer_type DROP NOT NULL,
ALTER COLUMN owner_peer_id DROP NOT NULL,
DROP CONSTRAINT IF EXISTS unique_star_gift_owner_check,
ADD COLUMN require_premium boolean DEFAULT false NOT NULL,
ADD COLUMN resale_ton_only boolean DEFAULT false NOT NULL,
ADD COLUMN theme_available boolean DEFAULT false NOT NULL,
ADD COLUMN burned boolean DEFAULT false NOT NULL,
ADD COLUMN crafted boolean DEFAULT false NOT NULL,
ADD COLUMN original_owner_peer_type text,
ADD COLUMN original_owner_peer_id bigint,
ADD COLUMN owner_name text DEFAULT '' NOT NULL,
ADD COLUMN owner_address text DEFAULT '' NOT NULL,
ADD COLUMN gift_address text DEFAULT '' NOT NULL,
ADD COLUMN released_by_peer_type text,
ADD COLUMN released_by_peer_id bigint,
ADD COLUMN value_amount bigint DEFAULT 0 NOT NULL,
ADD COLUMN value_currency text DEFAULT '' NOT NULL,
ADD COLUMN value_usd_amount bigint DEFAULT 0 NOT NULL,
ADD COLUMN theme_peer_type text,
ADD COLUMN theme_peer_id bigint,
ADD COLUMN host_peer_type text,
ADD COLUMN host_peer_id bigint,
ADD COLUMN offer_min_stars integer DEFAULT 0 NOT NULL,
ADD COLUMN craft_chance_permille integer DEFAULT 0 NOT NULL,
ADD COLUMN last_sale_date integer DEFAULT 0 NOT NULL,
ADD COLUMN last_sale_currency text DEFAULT '' NOT NULL,
ADD COLUMN last_sale_amount bigint DEFAULT 0 NOT NULL,
ADD CONSTRAINT unique_star_gift_owner_check CHECK (
(owner_peer_type IN ('user','channel') AND owner_peer_id > 0 AND owner_address='') OR
(owner_peer_type IS NULL AND owner_peer_id IS NULL AND owner_address<>'')
),
ADD CONSTRAINT unique_star_gift_released_by_check CHECK (
(released_by_peer_type IS NULL AND released_by_peer_id IS NULL) OR
(released_by_peer_type IN ('user','channel') AND released_by_peer_id > 0)
),
ADD CONSTRAINT unique_star_gift_theme_peer_check CHECK (
(theme_peer_type IS NULL AND theme_peer_id IS NULL) OR
(theme_peer_type IN ('user','channel') AND theme_peer_id > 0)
),
ADD CONSTRAINT unique_star_gift_host_peer_check CHECK (
(host_peer_type IS NULL AND host_peer_id IS NULL) OR
(host_peer_type IN ('user','channel') AND host_peer_id > 0)
),
ADD CONSTRAINT unique_star_gift_value_check CHECK (
value_amount >= 0 AND value_usd_amount >= 0 AND offer_min_stars >= 0 AND
craft_chance_permille BETWEEN 0 AND 1000 AND last_sale_date >= 0 AND last_sale_amount >= 0 AND
((value_currency='' AND value_amount=0) OR value_currency<>'') AND
((last_sale_currency='' AND last_sale_amount=0 AND last_sale_date=0) OR
(last_sale_currency IN ('XTR','TON') AND last_sale_amount>0 AND last_sale_date>0)) AND
(NOT burned OR owner_address='') AND
((owner_address='' AND gift_address='') OR (owner_address<>'' AND gift_address<>''))
);
UPDATE public.unique_star_gifts u
SET original_owner_peer_type=p.owner_peer_type, original_owner_peer_id=p.owner_peer_id
FROM public.peer_star_gifts p WHERE p.id=u.source_saved_gift_id;
ALTER TABLE public.unique_star_gifts
ALTER COLUMN original_owner_peer_type SET NOT NULL,
ALTER COLUMN original_owner_peer_id SET NOT NULL,
ADD CONSTRAINT unique_star_gift_original_owner_check CHECK (
original_owner_peer_type IN ('user','channel') AND original_owner_peer_id>0
);
CREATE TABLE public.star_gift_listings (
unique_gift_id bigint PRIMARY KEY REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
seller_peer_type text NOT NULL,
seller_peer_id bigint NOT NULL,
currency text NOT NULL,
amount bigint NOT NULL,
listed_at integer NOT NULL,
updated_at integer NOT NULL,
version bigint DEFAULT 1 NOT NULL,
CONSTRAINT star_gift_listing_seller_check CHECK (seller_peer_type IN ('user','channel') AND seller_peer_id>0),
CONSTRAINT star_gift_listing_amount_check CHECK (currency IN ('XTR','TON') AND amount>0 AND listed_at>0 AND updated_at>=listed_at)
);
CREATE INDEX star_gift_listings_gift_price_idx ON public.star_gift_listings(currency, amount, unique_gift_id);
CREATE INDEX star_gift_listings_updated_idx ON public.star_gift_listings(updated_at DESC, unique_gift_id DESC);
CREATE TABLE public.star_gift_sales (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
seller_peer_type text NOT NULL,
seller_peer_id bigint NOT NULL,
buyer_peer_type text NOT NULL,
buyer_peer_id bigint NOT NULL,
currency text NOT NULL,
amount bigint NOT NULL,
commission_amount bigint DEFAULT 0 NOT NULL,
sold_at integer NOT NULL,
command_key text NOT NULL,
CONSTRAINT star_gift_sales_command_uniq UNIQUE(command_key),
CONSTRAINT star_gift_sales_peer_check CHECK (
seller_peer_type IN ('user','channel') AND seller_peer_id>0 AND
buyer_peer_type IN ('user','channel') AND buyer_peer_id>0),
CONSTRAINT star_gift_sales_amount_check CHECK (
currency IN ('XTR','TON') AND amount>0 AND commission_amount>=0 AND commission_amount<=amount AND sold_at>0)
);
CREATE INDEX star_gift_sales_unique_date_idx ON public.star_gift_sales(unique_gift_id, sold_at DESC);
CREATE TABLE public.star_gift_offers (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
buyer_user_id bigint NOT NULL,
owner_peer_type text NOT NULL,
owner_peer_id bigint NOT NULL,
unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
currency text NOT NULL,
amount bigint NOT NULL,
random_id bigint NOT NULL,
offer_msg_id integer DEFAULT 0 NOT NULL,
buyer_msg_id integer DEFAULT 0 NOT NULL,
status text DEFAULT 'pending' NOT NULL,
created_at integer NOT NULL,
expires_at integer NOT NULL,
resolved_at integer DEFAULT 0 NOT NULL,
balance_after bigint DEFAULT 0 NOT NULL,
expiry_notified boolean DEFAULT false NOT NULL,
CONSTRAINT star_gift_offer_random_uniq UNIQUE(buyer_user_id, random_id),
CONSTRAINT star_gift_offer_owner_msg_uniq UNIQUE(owner_peer_type, owner_peer_id, offer_msg_id),
CONSTRAINT star_gift_offer_peer_check CHECK (buyer_user_id>0 AND owner_peer_type IN ('user','channel') AND owner_peer_id>0),
CONSTRAINT star_gift_offer_amount_check CHECK (currency IN ('XTR','TON') AND amount>0),
CONSTRAINT star_gift_offer_status_check CHECK (status IN ('pending','accepted','declined','expired','cancelled')),
CONSTRAINT star_gift_offer_time_check CHECK (created_at>0 AND expires_at>created_at AND resolved_at>=0 AND
((status='pending' AND resolved_at=0) OR (status<>'pending' AND resolved_at>=created_at)))
);
CREATE INDEX star_gift_offers_pending_expiry_idx ON public.star_gift_offers(expires_at, id) WHERE status='pending';
CREATE INDEX star_gift_offers_unique_pending_idx ON public.star_gift_offers(unique_gift_id, id) WHERE status='pending';
CREATE TABLE public.star_gift_transfer_commands (
actor_user_id bigint NOT NULL,
command_key text NOT NULL,
unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
from_peer_type text NOT NULL,
from_peer_id bigint NOT NULL,
to_peer_type text NOT NULL,
to_peer_id bigint NOT NULL,
charge_stars bigint DEFAULT 0 NOT NULL,
balance_after bigint DEFAULT 0 NOT NULL,
created_at integer NOT NULL,
CONSTRAINT star_gift_transfer_commands_pkey PRIMARY KEY(actor_user_id, command_key),
CONSTRAINT star_gift_transfer_command_peer_check CHECK (
actor_user_id>0 AND from_peer_type IN ('user','channel') AND from_peer_id>0 AND
to_peer_type IN ('user','channel') AND to_peer_id>0 AND charge_stars>=0 AND created_at>0)
);
CREATE TABLE public.star_gift_purchase_commands (
buyer_user_id bigint NOT NULL,
command_key text NOT NULL,
gift_id bigint NOT NULL REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT,
recipient_peer_type text NOT NULL,
recipient_peer_id bigint NOT NULL,
saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT,
form_id bigint NOT NULL,
charge_stars bigint NOT NULL,
balance_after bigint NOT NULL,
created_at integer NOT NULL,
CONSTRAINT star_gift_purchase_commands_pkey PRIMARY KEY(buyer_user_id,command_key),
CONSTRAINT star_gift_purchase_commands_form_uniq UNIQUE(buyer_user_id,form_id),
CONSTRAINT star_gift_purchase_command_shape_check CHECK (
buyer_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0 AND
form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0)
);
CREATE TABLE public.star_gift_prepaid_upgrade_commands (
payer_user_id bigint NOT NULL,
command_key text NOT NULL,
saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT,
form_id bigint NOT NULL,
charge_stars bigint NOT NULL,
balance_after bigint NOT NULL,
created_at integer NOT NULL,
CONSTRAINT star_gift_prepaid_upgrade_commands_pkey PRIMARY KEY(payer_user_id, command_key),
CONSTRAINT star_gift_prepaid_upgrade_commands_form_uniq UNIQUE(payer_user_id, form_id),
CONSTRAINT star_gift_prepaid_upgrade_command_shape_check CHECK (
payer_user_id>0 AND form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0)
);
CREATE TABLE public.star_gift_drop_details_commands (
user_id bigint NOT NULL,
command_key text NOT NULL,
saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT,
unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
form_id bigint NOT NULL,
charge_stars bigint NOT NULL,
balance_after bigint NOT NULL,
created_at integer NOT NULL,
CONSTRAINT star_gift_drop_details_commands_pkey PRIMARY KEY(user_id, command_key),
CONSTRAINT star_gift_drop_details_commands_form_uniq UNIQUE(user_id, form_id),
CONSTRAINT star_gift_drop_details_command_shape_check CHECK (
user_id>0 AND form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0)
);
CREATE TABLE public.star_gift_craft_commands (
user_id bigint NOT NULL,
command_key text NOT NULL,
input_unique_gift_ids bigint[] NOT NULL,
gift_id bigint NOT NULL REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT,
success boolean NOT NULL,
result_unique_gift_id bigint REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
chance_permille integer NOT NULL,
created_at integer NOT NULL,
CONSTRAINT star_gift_craft_commands_pkey PRIMARY KEY(user_id, command_key),
CONSTRAINT star_gift_craft_shape_check CHECK (
user_id>0 AND cardinality(input_unique_gift_ids) BETWEEN 1 AND 4 AND
chance_permille BETWEEN 0 AND 1000 AND created_at>0 AND
((success AND result_unique_gift_id IS NOT NULL) OR (NOT success AND result_unique_gift_id IS NULL)))
);
CREATE TABLE public.star_gift_notification_settings (
user_id bigint NOT NULL,
channel_id bigint NOT NULL,
enabled boolean DEFAULT true NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT star_gift_notification_settings_pkey PRIMARY KEY(user_id, channel_id),
CONSTRAINT star_gift_notification_settings_peer_check CHECK (user_id>0 AND channel_id>0)
);
CREATE TABLE public.star_gift_withdrawal_requests (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
owner_user_id bigint NOT NULL,
provider text NOT NULL,
provider_request_id text NOT NULL,
url text NOT NULL,
status text DEFAULT 'pending' NOT NULL,
created_at integer NOT NULL,
expires_at integer NOT NULL,
completed_at integer DEFAULT 0 NOT NULL,
CONSTRAINT star_gift_withdrawal_request_unique UNIQUE(unique_gift_id),
CONSTRAINT star_gift_withdrawal_provider_request_uniq UNIQUE(provider, provider_request_id),
CONSTRAINT star_gift_withdrawal_shape_check CHECK (
owner_user_id>0 AND provider<>'' AND provider_request_id<>'' AND url<>'' AND
status IN ('pending','completed','failed') AND created_at>0 AND expires_at>created_at AND completed_at>=0)
);
CREATE TABLE public.star_gift_auctions (
gift_id bigint PRIMARY KEY REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT,
slug text NOT NULL UNIQUE,
version integer DEFAULT 1 NOT NULL,
start_date integer NOT NULL,
end_date integer NOT NULL,
round_duration integer NOT NULL,
gifts_per_round integer NOT NULL,
total_rounds integer NOT NULL,
current_round integer DEFAULT 0 NOT NULL,
next_round_at integer NOT NULL,
last_gift_num integer DEFAULT 0 NOT NULL,
gifts_left integer NOT NULL,
min_bid_amount bigint NOT NULL,
status text DEFAULT 'pending' NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT star_gift_auction_shape_check CHECK (
version>0 AND start_date>0 AND end_date>start_date AND round_duration>0 AND
gifts_per_round>0 AND total_rounds>0 AND current_round BETWEEN 0 AND total_rounds AND
next_round_at>=start_date AND last_gift_num>=0 AND gifts_left>=0 AND min_bid_amount>0 AND
status IN ('pending','active','completed','cancelled'))
);
CREATE TABLE public.star_gift_auction_bids (
gift_id bigint NOT NULL REFERENCES public.star_gift_auctions(gift_id) ON DELETE RESTRICT,
bidder_user_id bigint NOT NULL,
recipient_peer_type text NOT NULL,
recipient_peer_id bigint NOT NULL,
amount bigint NOT NULL,
bid_date integer NOT NULL,
hide_name boolean DEFAULT false NOT NULL,
message text DEFAULT '' NOT NULL,
returned boolean DEFAULT false NOT NULL,
acquired_count integer DEFAULT 0 NOT NULL,
active boolean DEFAULT true NOT NULL,
version bigint DEFAULT 1 NOT NULL,
CONSTRAINT star_gift_auction_bids_pkey PRIMARY KEY(gift_id, bidder_user_id),
CONSTRAINT star_gift_auction_bid_peer_check CHECK (
bidder_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0),
CONSTRAINT star_gift_auction_bid_amount_check CHECK (amount>0 AND bid_date>0 AND acquired_count>=0 AND version>0)
);
CREATE INDEX star_gift_auction_bids_rank_idx ON public.star_gift_auction_bids(gift_id, amount DESC, bid_date, bidder_user_id) WHERE active;
CREATE TABLE public.star_gift_auction_bid_payments (
user_id bigint NOT NULL,
form_id bigint NOT NULL,
gift_id bigint NOT NULL REFERENCES public.star_gift_auctions(gift_id) ON DELETE RESTRICT,
bid_amount bigint NOT NULL,
balance_after bigint NOT NULL,
created_at integer NOT NULL,
CONSTRAINT star_gift_auction_bid_payments_pkey PRIMARY KEY(user_id, form_id),
CONSTRAINT star_gift_auction_bid_payment_shape_check CHECK (
user_id>0 AND form_id>0 AND bid_amount>0 AND balance_after>=0 AND created_at>0)
);
CREATE TABLE public.star_gift_auction_acquired (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
gift_id bigint NOT NULL REFERENCES public.star_gift_auctions(gift_id) ON DELETE RESTRICT,
bidder_user_id bigint NOT NULL,
recipient_peer_type text NOT NULL,
recipient_peer_id bigint NOT NULL,
saved_gift_id bigint REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT,
bid_amount bigint NOT NULL,
round integer NOT NULL,
pos integer NOT NULL,
gift_num integer,
acquired_at integer NOT NULL,
hide_name boolean DEFAULT false NOT NULL,
message text DEFAULT '' NOT NULL,
CONSTRAINT star_gift_auction_acquired_round_pos_uniq UNIQUE(gift_id, round, pos),
CONSTRAINT star_gift_auction_acquired_shape_check CHECK (
bidder_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0 AND
bid_amount>0 AND round>0 AND pos>0 AND acquired_at>0 AND (gift_num IS NULL OR gift_num>0))
);
CREATE INDEX star_gift_auction_acquired_user_idx ON public.star_gift_auction_acquired(bidder_user_id, gift_id, id);
CREATE TABLE public.ton_balances (
user_id bigint PRIMARY KEY,
balance_nanoton bigint DEFAULT 0 NOT NULL,
granted boolean DEFAULT false NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT ton_balances_check CHECK (user_id>0 AND balance_nanoton>=0)
);
CREATE TABLE public.ton_transactions (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL,
amount_nanoton bigint NOT NULL,
reason text NOT NULL,
peer_type text,
peer_id bigint,
gift_id bigint,
date integer NOT NULL,
CONSTRAINT ton_transaction_amount_check CHECK (user_id>0 AND amount_nanoton<>0 AND date>0),
CONSTRAINT ton_transaction_peer_check CHECK (
(peer_type IS NULL AND peer_id IS NULL) OR (peer_type IN ('user','channel') AND peer_id>0))
);
CREATE INDEX ton_transactions_user_idx ON public.ton_transactions(user_id, id DESC);
CREATE FUNCTION public.telesrv_guard_star_gift_listing() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
gift_owner_type text;
gift_owner_id bigint;
gift_burned boolean;
BEGIN
SELECT owner_peer_type, owner_peer_id, burned
INTO gift_owner_type, gift_owner_id, gift_burned
FROM public.unique_star_gifts WHERE id=NEW.unique_gift_id FOR SHARE;
IF gift_burned OR gift_owner_type IS DISTINCT FROM NEW.seller_peer_type OR gift_owner_id IS DISTINCT FROM NEW.seller_peer_id THEN
RAISE EXCEPTION 'star gift listing owner/state mismatch';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER star_gift_listing_guard BEFORE INSERT OR UPDATE ON public.star_gift_listings
FOR EACH ROW EXECUTE FUNCTION public.telesrv_guard_star_gift_listing();
CREATE FUNCTION public.telesrv_check_unique_star_gift_owner() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
unique_id bigint;
gift_owner_type text;
gift_owner_id bigint;
gift_owner_address text;
gift_burned boolean;
saved_status text;
saved_owner_type text;
saved_owner_id bigint;
BEGIN
IF TG_TABLE_NAME = 'unique_star_gifts' THEN
unique_id := COALESCE(NEW.id, OLD.id);
ELSE
unique_id := COALESCE(NEW.unique_gift_id, OLD.unique_gift_id);
END IF;
IF unique_id IS NULL THEN RETURN NULL; END IF;
SELECT owner_peer_type, owner_peer_id, owner_address, burned
INTO gift_owner_type, gift_owner_id, gift_owner_address, gift_burned
FROM public.unique_star_gifts WHERE id=unique_id;
IF NOT FOUND THEN RETURN NULL; END IF;
SELECT lifecycle_status, owner_peer_type, owner_peer_id
INTO saved_status, saved_owner_type, saved_owner_id
FROM public.peer_star_gifts WHERE unique_gift_id=unique_id;
IF NOT FOUND THEN RAISE EXCEPTION 'unique star gift missing saved aggregate'; END IF;
IF gift_burned THEN
IF saved_status <> 'burned' THEN RAISE EXCEPTION 'burned unique star gift has live saved aggregate'; END IF;
ELSIF gift_owner_address <> '' THEN
IF saved_status <> 'exported' THEN RAISE EXCEPTION 'exported unique star gift has non-exported saved aggregate'; END IF;
ELSIF saved_status <> 'active' OR gift_owner_type IS DISTINCT FROM saved_owner_type OR gift_owner_id IS DISTINCT FROM saved_owner_id THEN
RAISE EXCEPTION 'unique star gift owner mismatch';
END IF;
RETURN NULL;
END;
$$;
CREATE CONSTRAINT TRIGGER unique_star_gift_owner_guard
AFTER INSERT OR UPDATE ON public.unique_star_gifts DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION public.telesrv_check_unique_star_gift_owner();
CREATE CONSTRAINT TRIGGER peer_unique_star_gift_owner_guard
AFTER INSERT OR UPDATE ON public.peer_star_gifts DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW WHEN (NEW.unique_gift_id IS NOT NULL)
EXECUTE FUNCTION public.telesrv_check_unique_star_gift_owner();

View file

@ -0,0 +1,6 @@
DROP INDEX IF EXISTS public.star_gift_auction_acquired_delivery_idx;
DROP INDEX IF EXISTS public.star_gift_auctions_due_idx;
DROP INDEX IF EXISTS public.star_gift_offers_resolution_outbox_idx;
ALTER TABLE public.star_gift_offers
RENAME COLUMN resolution_notified TO expiry_notified;

View file

@ -0,0 +1,16 @@
-- Durable lifecycle sweep support. 0095 originally named this column after the
-- first use case (expiry); cancelled offers use the same outbox boundary.
ALTER TABLE public.star_gift_offers
RENAME COLUMN expiry_notified TO resolution_notified;
CREATE INDEX star_gift_offers_resolution_outbox_idx
ON public.star_gift_offers(id)
WHERE status IN ('expired','cancelled') AND NOT resolution_notified;
CREATE INDEX star_gift_auctions_due_idx
ON public.star_gift_auctions(status, next_round_at, gift_id)
WHERE status IN ('pending','active');
CREATE INDEX star_gift_auction_acquired_delivery_idx
ON public.star_gift_auction_acquired(gift_id, id)
WHERE saved_gift_id IS NULL;

View file

@ -0,0 +1,3 @@
DROP TABLE IF EXISTS public.star_gift_conversions;
DROP TABLE IF EXISTS public.channel_stars_transactions;
DROP TABLE IF EXISTS public.channel_stars_balances;

View file

@ -0,0 +1,40 @@
-- Owner-scoped Stars revenue for channel Star Gifts. These are internal
-- telesrv ledgers only; no blockchain, wallet, TON node or Fragment endpoint is
-- contacted. Conversion is recorded as one aggregate terminal transition.
CREATE TABLE public.channel_stars_balances (
channel_id bigint PRIMARY KEY,
balance bigint DEFAULT 0 NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT channel_stars_balances_shape_check CHECK (channel_id>0 AND balance>=0)
);
CREATE TABLE public.channel_stars_transactions (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
channel_id bigint NOT NULL,
actor_user_id bigint NOT NULL,
amount bigint NOT NULL,
reason text NOT NULL,
peer_type text DEFAULT '' NOT NULL,
peer_id bigint DEFAULT 0 NOT NULL,
gift_id bigint,
date integer NOT NULL,
CONSTRAINT channel_stars_transactions_shape_check CHECK (
channel_id>0 AND actor_user_id>0 AND amount<>0 AND date>0 AND
((peer_type='' AND peer_id=0) OR (peer_type IN ('user','channel') AND peer_id>0)))
);
CREATE INDEX channel_stars_transactions_channel_idx
ON public.channel_stars_transactions(channel_id,id DESC);
CREATE TABLE public.star_gift_conversions (
saved_gift_id bigint PRIMARY KEY REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT,
actor_user_id bigint NOT NULL,
owner_peer_type text NOT NULL,
owner_peer_id bigint NOT NULL,
amount bigint NOT NULL,
balance_after bigint NOT NULL,
converted_at integer NOT NULL,
CONSTRAINT star_gift_conversions_shape_check CHECK (
actor_user_id>0 AND owner_peer_type IN ('user','channel') AND owner_peer_id>0 AND
amount>=0 AND balance_after>=0 AND converted_at>0)
);

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS public.channel_ton_transactions;
DROP TABLE IF EXISTS public.channel_ton_balances;

View file

@ -0,0 +1,25 @@
-- TON-denominated channel marketplace proceeds remain a telesrv-local ledger.
-- No wallet, chain node, smart contract, Fragment or external RPC is involved.
CREATE TABLE public.channel_ton_balances (
channel_id bigint PRIMARY KEY,
balance_nanoton bigint DEFAULT 0 NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT channel_ton_balances_shape_check CHECK (channel_id>0 AND balance_nanoton>=0)
);
CREATE TABLE public.channel_ton_transactions (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
channel_id bigint NOT NULL,
actor_user_id bigint NOT NULL,
amount_nanoton bigint NOT NULL,
reason text NOT NULL,
peer_type text DEFAULT '' NOT NULL,
peer_id bigint DEFAULT 0 NOT NULL,
gift_id bigint,
date integer NOT NULL,
CONSTRAINT channel_ton_transactions_shape_check CHECK (
channel_id>0 AND actor_user_id>0 AND amount_nanoton<>0 AND date>0 AND
((peer_type='' AND peer_id=0) OR (peer_type IN ('user','channel') AND peer_id>0)))
);
CREATE INDEX channel_ton_transactions_channel_idx
ON public.channel_ton_transactions(channel_id,id DESC);

View file

@ -0,0 +1,20 @@
ALTER TABLE public.star_gift_purchase_commands
DROP CONSTRAINT star_gift_purchase_command_shape_check,
ADD CONSTRAINT star_gift_purchase_command_shape_check CHECK (
buyer_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0 AND
form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0) NOT VALID;
ALTER TABLE public.star_gift_prepaid_upgrade_commands
DROP CONSTRAINT star_gift_prepaid_upgrade_command_shape_check,
ADD CONSTRAINT star_gift_prepaid_upgrade_command_shape_check CHECK (
payer_user_id>0 AND form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0) NOT VALID;
ALTER TABLE public.star_gift_drop_details_commands
DROP CONSTRAINT star_gift_drop_details_command_shape_check,
ADD CONSTRAINT star_gift_drop_details_command_shape_check CHECK (
user_id>0 AND form_id>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0) NOT VALID;
ALTER TABLE public.star_gift_auction_bid_payments
DROP CONSTRAINT star_gift_auction_bid_payment_shape_check,
ADD CONSTRAINT star_gift_auction_bid_payment_shape_check CHECK (
user_id>0 AND form_id>0 AND bid_amount>0 AND balance_after>=0 AND created_at>0) NOT VALID;

View file

@ -0,0 +1,20 @@
ALTER TABLE public.star_gift_purchase_commands
DROP CONSTRAINT star_gift_purchase_command_shape_check,
ADD CONSTRAINT star_gift_purchase_command_shape_check CHECK (
buyer_user_id>0 AND recipient_peer_type IN ('user','channel') AND recipient_peer_id>0 AND
form_id<>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0);
ALTER TABLE public.star_gift_prepaid_upgrade_commands
DROP CONSTRAINT star_gift_prepaid_upgrade_command_shape_check,
ADD CONSTRAINT star_gift_prepaid_upgrade_command_shape_check CHECK (
payer_user_id>0 AND form_id<>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0);
ALTER TABLE public.star_gift_drop_details_commands
DROP CONSTRAINT star_gift_drop_details_command_shape_check,
ADD CONSTRAINT star_gift_drop_details_command_shape_check CHECK (
user_id>0 AND form_id<>0 AND charge_stars>0 AND balance_after>=0 AND created_at>0);
ALTER TABLE public.star_gift_auction_bid_payments
DROP CONSTRAINT star_gift_auction_bid_payment_shape_check,
ADD CONSTRAINT star_gift_auction_bid_payment_shape_check CHECK (
user_id>0 AND form_id<>0 AND bid_amount>0 AND balance_after>=0 AND created_at>0);

View file

@ -0,0 +1,35 @@
UPDATE public.message_boxes
SET media = jsonb_set(media, '{service_action,star_gift,upgrade_stars}', media #> '{service_action,star_gift,upgrade_price_stars}', true)
#- '{service_action,star_gift,upgrade_price_stars}'
WHERE media #>> '{service_action,kind}' = 'star_gift'
AND media #> '{service_action,star_gift,upgrade_price_stars}' IS NOT NULL;
UPDATE public.channel_messages
SET action = jsonb_set(action, '{StarGift,upgrade_stars}', action #> '{StarGift,upgrade_price_stars}', true)
#- '{StarGift,upgrade_price_stars}'
WHERE action #>> '{Type}' = 'star_gift'
AND action #> '{StarGift,upgrade_price_stars}' IS NOT NULL;
UPDATE public.channel_admin_log_events
SET message = jsonb_set(message, '{Action,StarGift,upgrade_stars}', message #> '{Action,StarGift,upgrade_price_stars}', true)
#- '{Action,StarGift,upgrade_price_stars}'
WHERE message #>> '{Action,Type}' = 'star_gift'
AND message #> '{Action,StarGift,upgrade_price_stars}' IS NOT NULL;
UPDATE public.channel_admin_log_events
SET prev_message = jsonb_set(prev_message, '{Action,StarGift,upgrade_stars}', prev_message #> '{Action,StarGift,upgrade_price_stars}', true)
#- '{Action,StarGift,upgrade_price_stars}'
WHERE prev_message #>> '{Action,Type}' = 'star_gift'
AND prev_message #> '{Action,StarGift,upgrade_price_stars}' IS NOT NULL;
UPDATE public.channel_admin_log_events
SET new_message = jsonb_set(new_message, '{Action,StarGift,upgrade_stars}', new_message #> '{Action,StarGift,upgrade_price_stars}', true)
#- '{Action,StarGift,upgrade_price_stars}'
WHERE new_message #>> '{Action,Type}' = 'star_gift'
AND new_message #> '{Action,StarGift,upgrade_price_stars}' IS NOT NULL;
ALTER TABLE public.star_gift_upgrade_commands
DROP CONSTRAINT IF EXISTS star_gift_upgrade_command_replay_shape_check,
DROP COLUMN IF EXISTS keep_original_details,
DROP COLUMN IF EXISTS require_prepaid,
DROP COLUMN IF EXISTS charge_stars;

View file

@ -0,0 +1,79 @@
-- Split the two TL fields that TDesktop consumes differently:
-- StarGift.upgrade_stars = current paid-upgrade price
-- messageActionStarGift.upgrade_stars = amount already prepaid by sender
-- Also persist the immutable command envelope required to replay an upgrade
-- after the saved gift has entered its unique terminal state.
ALTER TABLE public.star_gift_upgrade_commands
ADD COLUMN charge_stars bigint NOT NULL DEFAULT 0,
ADD COLUMN require_prepaid boolean NOT NULL DEFAULT false,
ADD COLUMN keep_original_details boolean NOT NULL DEFAULT false;
UPDATE public.star_gift_upgrade_commands c
SET charge_stars = CASE WHEN c.form_id = 0 THEN 0 ELSE r.upgrade_stars END,
require_prepaid = (c.form_id = 0),
keep_original_details = u.keep_original_details
FROM public.unique_star_gifts u
JOIN public.star_gift_collectible_revisions r ON r.id = u.collectible_revision_id
WHERE u.id = c.unique_gift_id;
ALTER TABLE public.star_gift_upgrade_commands
ADD CONSTRAINT star_gift_upgrade_command_replay_shape_check CHECK (
(require_prepaid AND form_id = 0 AND charge_stars = 0)
OR
(NOT require_prepaid AND form_id <> 0 AND charge_stars > 0)
);
-- Private message boxes are the canonical durable snapshots used by history,
-- live outbox delivery and updates.getDifference.
UPDATE public.message_boxes
SET media = CASE
WHEN COALESCE((media #>> '{service_action,star_gift,prepaid_upgrade}')::boolean, false)
THEN jsonb_set(media, '{service_action,star_gift,upgrade_price_stars}', media #> '{service_action,star_gift,upgrade_stars}', true)
ELSE jsonb_set(media, '{service_action,star_gift,upgrade_price_stars}', media #> '{service_action,star_gift,upgrade_stars}', true)
#- '{service_action,star_gift,upgrade_stars}'
END
WHERE media #>> '{service_action,kind}' = 'star_gift'
AND media #> '{service_action,star_gift,upgrade_stars}' IS NOT NULL;
-- Channel service-message and Recent Actions snapshots use exported Go field
-- names for their outer objects and the same snake_case StarGift payload.
UPDATE public.channel_messages
SET action = CASE
WHEN COALESCE((action #>> '{StarGift,prepaid_upgrade}')::boolean, false)
THEN jsonb_set(action, '{StarGift,upgrade_price_stars}', action #> '{StarGift,upgrade_stars}', true)
ELSE jsonb_set(action, '{StarGift,upgrade_price_stars}', action #> '{StarGift,upgrade_stars}', true)
#- '{StarGift,upgrade_stars}'
END
WHERE action #>> '{Type}' = 'star_gift'
AND action #> '{StarGift,upgrade_stars}' IS NOT NULL;
UPDATE public.channel_admin_log_events
SET message = CASE
WHEN COALESCE((message #>> '{Action,StarGift,prepaid_upgrade}')::boolean, false)
THEN jsonb_set(message, '{Action,StarGift,upgrade_price_stars}', message #> '{Action,StarGift,upgrade_stars}', true)
ELSE jsonb_set(message, '{Action,StarGift,upgrade_price_stars}', message #> '{Action,StarGift,upgrade_stars}', true)
#- '{Action,StarGift,upgrade_stars}'
END
WHERE message #>> '{Action,Type}' = 'star_gift'
AND message #> '{Action,StarGift,upgrade_stars}' IS NOT NULL;
UPDATE public.channel_admin_log_events
SET prev_message = CASE
WHEN COALESCE((prev_message #>> '{Action,StarGift,prepaid_upgrade}')::boolean, false)
THEN jsonb_set(prev_message, '{Action,StarGift,upgrade_price_stars}', prev_message #> '{Action,StarGift,upgrade_stars}', true)
ELSE jsonb_set(prev_message, '{Action,StarGift,upgrade_price_stars}', prev_message #> '{Action,StarGift,upgrade_stars}', true)
#- '{Action,StarGift,upgrade_stars}'
END
WHERE prev_message #>> '{Action,Type}' = 'star_gift'
AND prev_message #> '{Action,StarGift,upgrade_stars}' IS NOT NULL;
UPDATE public.channel_admin_log_events
SET new_message = CASE
WHEN COALESCE((new_message #>> '{Action,StarGift,prepaid_upgrade}')::boolean, false)
THEN jsonb_set(new_message, '{Action,StarGift,upgrade_price_stars}', new_message #> '{Action,StarGift,upgrade_stars}', true)
ELSE jsonb_set(new_message, '{Action,StarGift,upgrade_price_stars}', new_message #> '{Action,StarGift,upgrade_stars}', true)
#- '{Action,StarGift,upgrade_stars}'
END
WHERE new_message #>> '{Action,Type}' = 'star_gift'
AND new_message #> '{Action,StarGift,upgrade_stars}' IS NOT NULL;

View file

@ -0,0 +1,3 @@
-- The up migration emits user-visible durable update facts. Rewinding pts or
-- deleting events that may already have been delivered would create a hole in
-- updates.getDifference, so rollback intentionally preserves those facts.

View file

@ -0,0 +1,74 @@
-- Migration 0100 corrected the durable Star Gift service-message JSON, but a
-- TDesktop that had already cached the old message would otherwise keep using
-- the conflated outer upgrade_stars field. Publish one durable edit event for
-- every private message box whose paid-upgrade price was split by 0100.
--
-- This is deliberately a pts event, not a cache-only notification: history,
-- updates.getDifference and online outbox delivery must all expose the same
-- corrected message snapshot. The migration transaction keeps the message
-- pts, user watermark, durable event and dispatch task atomic.
DO $$
DECLARE
gift_box record;
next_pts integer;
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
BEGIN
FOR gift_box IN
SELECT owner_user_id, box_id, peer_type, peer_id
FROM public.message_boxes
WHERE media #>> '{service_action,kind}' = 'star_gift'
AND jsonb_typeof(media #> '{service_action,star_gift,upgrade_price_stars}') = 'number'
AND (media #>> '{service_action,star_gift,upgrade_price_stars}')::bigint > 0
ORDER BY owner_user_id, box_id
LOOP
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
VALUES (gift_box.owner_user_id, 0)
ON CONFLICT (user_id) DO NOTHING;
UPDATE public.user_update_watermarks
SET contiguous_pts = contiguous_pts + 1,
updated_at = now()
WHERE user_id = gift_box.owner_user_id
RETURNING contiguous_pts INTO next_pts;
UPDATE public.message_boxes
SET pts = next_pts
WHERE owner_user_id = gift_box.owner_user_id
AND box_id = gift_box.box_id;
INSERT INTO public.user_update_events (
user_id,
pts,
pts_count,
date,
event_type,
message_box_id,
peer_type,
peer_id
) VALUES (
gift_box.owner_user_id,
next_pts,
1,
event_date,
'edit_message',
gift_box.box_id,
gift_box.peer_type,
gift_box.peer_id
);
INSERT INTO public.dispatch_outbox (
target_user_id,
pts,
event_type,
exclude_auth_key_id,
exclude_session_id
) VALUES (
gift_box.owner_user_id,
next_pts,
'edit_message',
0,
0
);
END LOOP;
END
$$;

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.star_gift_purchase_forms;

View file

@ -0,0 +1,23 @@
CREATE TABLE public.star_gift_purchase_forms (
buyer_user_id bigint NOT NULL,
form_id bigint NOT NULL,
gift_id bigint NOT NULL REFERENCES public.star_gift_catalog(gift_id) ON DELETE RESTRICT,
revision_id bigint NOT NULL REFERENCES public.star_gift_catalog_revisions(id) ON DELETE RESTRICT,
recipient_peer_type text NOT NULL,
recipient_peer_id bigint NOT NULL,
include_upgrade boolean DEFAULT false NOT NULL,
hide_name boolean DEFAULT false NOT NULL,
message text DEFAULT '' NOT NULL,
charge_stars bigint NOT NULL,
issued_at integer NOT NULL,
expires_at integer NOT NULL,
CONSTRAINT star_gift_purchase_forms_pkey PRIMARY KEY (buyer_user_id, form_id),
CONSTRAINT star_gift_purchase_form_shape_check CHECK (
buyer_user_id > 0 AND form_id <> 0 AND gift_id > 0 AND revision_id > 0 AND
recipient_peer_type IN ('user', 'channel') AND recipient_peer_id > 0 AND
charge_stars > 0 AND issued_at > 0 AND expires_at = issued_at + 600 AND
char_length(message) <= 128)
);
CREATE INDEX star_gift_purchase_forms_expiry_idx
ON public.star_gift_purchase_forms (expires_at, buyer_user_id, form_id);

View file

@ -0,0 +1,5 @@
-- Projection repair events are durable account history and are intentionally
-- not erased on rollback. Only remove the replay receipt column.
ALTER TABLE public.star_gift_upgrade_commands
DROP CONSTRAINT IF EXISTS star_gift_upgrade_command_source_edit_pts_check,
DROP COLUMN IF EXISTS source_edit_pts;

View file

@ -0,0 +1,191 @@
-- A user-owned upgraded gift has one stable protocol identity: the original
-- gift service-message id. The unique service message points back to it via
-- saved_id, while the original message points forward to the box-local unique
-- service message via upgrade_msg_id. Both projections must be durable pts
-- edits so history, live delivery and updates.getDifference agree.
ALTER TABLE public.star_gift_upgrade_commands
ADD COLUMN source_edit_pts integer DEFAULT 0 NOT NULL,
ADD CONSTRAINT star_gift_upgrade_command_source_edit_pts_check CHECK (source_edit_pts >= 0);
DO $$
DECLARE
gift record;
source_root record;
upgrade_root record;
pair record;
next_pts integer;
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
repaired_source_media jsonb;
repaired_unique_media jsonb;
private_media jsonb;
BEGIN
FOR gift IN
SELECT id, owner_peer_id, msg_id, upgrade_msg_id
FROM public.peer_star_gifts
WHERE owner_peer_type = 'user'
AND unique_gift_id IS NOT NULL
AND lifecycle_status = 'active'
AND msg_id > 0
AND upgrade_msg_id > 0
ORDER BY id
LOOP
SELECT private_message_id, message_sender_id
INTO STRICT source_root
FROM public.message_boxes
WHERE owner_user_id = gift.owner_peer_id
AND box_id = gift.msg_id
AND NOT deleted;
SELECT private_message_id, message_sender_id
INTO STRICT upgrade_root
FROM public.message_boxes
WHERE owner_user_id = gift.owner_peer_id
AND box_id = gift.upgrade_msg_id
AND NOT deleted;
FOR pair IN
SELECT source_box.owner_user_id,
source_box.box_id AS source_box_id,
source_box.peer_type AS source_peer_type,
source_box.peer_id AS source_peer_id,
source_box.media AS source_media,
unique_box.box_id AS unique_box_id,
unique_box.peer_type AS unique_peer_type,
unique_box.peer_id AS unique_peer_id,
unique_box.media AS unique_media
FROM public.message_boxes source_box
JOIN public.message_boxes unique_box
ON unique_box.owner_user_id = source_box.owner_user_id
AND unique_box.message_sender_id = upgrade_root.message_sender_id
AND unique_box.private_message_id = upgrade_root.private_message_id
AND NOT unique_box.deleted
WHERE source_box.message_sender_id = source_root.message_sender_id
AND source_box.private_message_id = source_root.private_message_id
AND NOT source_box.deleted
ORDER BY source_box.owner_user_id
LOOP
IF pair.source_media #>> '{service_action,kind}' <> 'star_gift' THEN
RAISE EXCEPTION 'saved gift % source box % has invalid service action', gift.id, pair.source_box_id;
END IF;
IF pair.unique_media #>> '{service_action,kind}' <> 'star_gift_unique' THEN
RAISE EXCEPTION 'saved gift % unique box % has invalid service action', gift.id, pair.unique_box_id;
END IF;
repaired_source_media := jsonb_set(
pair.source_media,
'{service_action,star_gift,upgrade_msg_id}',
to_jsonb(pair.unique_box_id::bigint),
true
) #- '{service_action,star_gift,can_upgrade}';
repaired_unique_media := jsonb_set(
pair.unique_media,
'{service_action,star_gift_unique,saved_id}',
to_jsonb(pair.source_box_id::bigint),
true
);
IF pair.source_media IS DISTINCT FROM repaired_source_media THEN
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
VALUES (pair.owner_user_id, 0)
ON CONFLICT (user_id) DO NOTHING;
UPDATE public.user_update_watermarks
SET contiguous_pts = contiguous_pts + 1,
updated_at = now()
WHERE user_id = pair.owner_user_id
RETURNING contiguous_pts INTO next_pts;
UPDATE public.message_boxes
SET media = repaired_source_media,
pts = next_pts
WHERE owner_user_id = pair.owner_user_id
AND box_id = pair.source_box_id
AND NOT deleted;
INSERT INTO public.user_update_events (
user_id, pts, pts_count, date, event_type,
message_box_id, peer_type, peer_id
) VALUES (
pair.owner_user_id, next_pts, 1, event_date, 'edit_message',
pair.source_box_id, pair.source_peer_type, pair.source_peer_id
);
INSERT INTO public.dispatch_outbox (
target_user_id, pts, event_type,
exclude_auth_key_id, exclude_session_id
) VALUES (pair.owner_user_id, next_pts, 'edit_message', 0, 0);
IF pair.owner_user_id = gift.owner_peer_id THEN
UPDATE public.star_gift_upgrade_commands
SET source_edit_pts = next_pts
WHERE source_saved_gift_id = gift.id;
END IF;
END IF;
IF pair.unique_media IS DISTINCT FROM repaired_unique_media THEN
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
VALUES (pair.owner_user_id, 0)
ON CONFLICT (user_id) DO NOTHING;
UPDATE public.user_update_watermarks
SET contiguous_pts = contiguous_pts + 1,
updated_at = now()
WHERE user_id = pair.owner_user_id
RETURNING contiguous_pts INTO next_pts;
UPDATE public.message_boxes
SET media = repaired_unique_media,
pts = next_pts
WHERE owner_user_id = pair.owner_user_id
AND box_id = pair.unique_box_id
AND NOT deleted;
INSERT INTO public.user_update_events (
user_id, pts, pts_count, date, event_type,
message_box_id, peer_type, peer_id
) VALUES (
pair.owner_user_id, next_pts, 1, event_date, 'edit_message',
pair.unique_box_id, pair.unique_peer_type, pair.unique_peer_id
);
INSERT INTO public.dispatch_outbox (
target_user_id, pts, event_type,
exclude_auth_key_id, exclude_session_id
) VALUES (pair.owner_user_id, next_pts, 'edit_message', 0, 0);
END IF;
END LOOP;
SELECT media INTO STRICT private_media
FROM public.message_boxes
WHERE message_sender_id = source_root.message_sender_id
AND private_message_id = source_root.private_message_id
AND NOT deleted
ORDER BY (owner_user_id = message_sender_id) DESC, owner_user_id
LIMIT 1;
UPDATE public.private_messages
SET media = private_media
WHERE sender_user_id = source_root.message_sender_id
AND id = source_root.private_message_id;
SELECT media INTO STRICT private_media
FROM public.message_boxes
WHERE message_sender_id = upgrade_root.message_sender_id
AND private_message_id = upgrade_root.private_message_id
AND NOT deleted
ORDER BY (owner_user_id = message_sender_id) DESC, owner_user_id
LIMIT 1;
UPDATE public.private_messages
SET media = private_media
WHERE sender_user_id = upgrade_root.message_sender_id
AND id = upgrade_root.private_message_id;
IF EXISTS (
SELECT 1 FROM public.star_gift_upgrade_commands
WHERE source_saved_gift_id = gift.id AND source_edit_pts <= 0
) THEN
RAISE EXCEPTION 'saved gift % is missing its owner source edit receipt', gift.id;
END IF;
END LOOP;
END
$$;

View file

@ -0,0 +1,4 @@
-- This migration repairs invalid persisted capabilities and intentionally does
-- not restore them on downgrade: advertising Craft without an official crafted
-- model would reintroduce a user-visible operation that can never succeed.
SELECT 1;

View file

@ -0,0 +1,109 @@
-- Craft is a protocol capability, not a generic property of every collectible.
-- A non-zero craft_chance_permille is valid only when the immutable official
-- collectible revision contains at least one crafted model. Earlier versions
-- advertised the default chance for every upgrade, including official sets
-- such as Fresh Socks whose snapshot has no crafted model at all.
--
-- Repair the aggregate and every durable private-message projection together.
-- Each visible message edit advances pts and is recoverable through both live
-- outbox delivery and updates.getDifference.
DO $$
DECLARE
gift_box record;
next_pts integer;
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
repaired_media jsonb;
BEGIN
UPDATE public.unique_star_gifts unique_gift
SET craft_chance_permille = 0,
updated_at = now()
WHERE unique_gift.craft_chance_permille > 0
AND NOT EXISTS (
SELECT 1
FROM public.star_gift_collectible_models model
WHERE model.collectible_revision_id = unique_gift.collectible_revision_id
AND model.crafted
);
UPDATE public.peer_star_gifts saved_gift
SET can_craft_at = 0
FROM public.unique_star_gifts unique_gift
WHERE unique_gift.id = saved_gift.unique_gift_id
AND unique_gift.craft_chance_permille = 0
AND NOT EXISTS (
SELECT 1
FROM public.star_gift_collectible_models model
WHERE model.collectible_revision_id = unique_gift.collectible_revision_id
AND model.crafted
);
FOR gift_box IN
SELECT box.owner_user_id,
box.box_id,
box.peer_type,
box.peer_id,
box.message_sender_id,
box.private_message_id,
box.media
FROM public.message_boxes box
JOIN public.unique_star_gifts unique_gift
ON unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint
WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND jsonb_typeof(box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}') = 'number'
AND (box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}')::integer > 0
AND NOT EXISTS (
SELECT 1
FROM public.star_gift_collectible_models model
WHERE model.collectible_revision_id = unique_gift.collectible_revision_id
AND model.crafted
)
ORDER BY box.owner_user_id, box.box_id
LOOP
repaired_media := gift_box.media
#- '{service_action,star_gift_unique,gift,CraftChancePermille}'
#- '{service_action,star_gift_unique,can_craft_at}';
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
VALUES (gift_box.owner_user_id, 0)
ON CONFLICT (user_id) DO NOTHING;
UPDATE public.user_update_watermarks
SET contiguous_pts = contiguous_pts + 1,
updated_at = now()
WHERE user_id = gift_box.owner_user_id
RETURNING contiguous_pts INTO next_pts;
UPDATE public.message_boxes
SET media = repaired_media,
pts = next_pts
WHERE owner_user_id = gift_box.owner_user_id
AND box_id = gift_box.box_id
AND NOT deleted;
UPDATE public.private_messages
SET media = media
#- '{service_action,star_gift_unique,gift,CraftChancePermille}'
#- '{service_action,star_gift_unique,can_craft_at}',
sender_snapshot = sender_snapshot
#- '{message,Media,service_action,star_gift_unique,gift,CraftChancePermille}'
#- '{message,Media,service_action,star_gift_unique,can_craft_at}'
WHERE sender_user_id = gift_box.message_sender_id
AND id = gift_box.private_message_id;
INSERT INTO public.user_update_events (
user_id, pts, pts_count, date, event_type,
message_box_id, peer_type, peer_id
) VALUES (
gift_box.owner_user_id, next_pts, 1, event_date, 'edit_message',
gift_box.box_id, gift_box.peer_type, gift_box.peer_id
);
INSERT INTO public.dispatch_outbox (
target_user_id, pts, event_type,
exclude_auth_key_id, exclude_session_id
) VALUES (
gift_box.owner_user_id, next_pts, 'edit_message', 0, 0
);
END LOOP;
END
$$;

View file

@ -0,0 +1,7 @@
ALTER TABLE public.star_gift_craft_commands
DROP CONSTRAINT IF EXISTS star_gift_craft_source_edit_shape_check,
DROP COLUMN IF EXISTS source_edit_pts;
-- Burned/crafted lifecycle facts and emitted edit events are authoritative
-- business history. Downgrade intentionally does not resurrect consumed gifts.
SELECT 1;

View file

@ -0,0 +1,195 @@
-- Craft outcomes consume their inputs permanently. Keep the aggregate, every
-- visible messageActionStarGiftUnique snapshot, pts/outbox delivery and command
-- replay receipt in one state model.
ALTER TABLE public.star_gift_craft_commands
ADD COLUMN source_edit_pts integer[] DEFAULT ARRAY[]::integer[] NOT NULL;
DO $$
DECLARE
gift_box record;
next_pts integer;
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
repaired_media jsonb;
BEGIN
-- A successful legacy command would require reconstructing the crafted
-- model snapshot, not merely flipping flags. Fail fast instead of silently
-- fabricating that projection; the development database must be rebuilt or
-- repaired explicitly if such a row ever exists.
IF EXISTS (SELECT 1 FROM public.star_gift_craft_commands WHERE success) THEN
RAISE EXCEPTION 'cannot migrate legacy successful craft command without an exact crafted message projection';
END IF;
-- upgrade_msg_id means the current owner's unique service-message
-- projection, not permanently the first upgrade message. Ownership moves
-- replace msg_id with the new transfer/resale/offer message; repair rows
-- written before that invariant was enforced.
UPDATE public.peer_star_gifts saved_gift
SET upgrade_msg_id = saved_gift.msg_id
WHERE saved_gift.owner_peer_type = 'user'
AND saved_gift.unique_gift_id IS NOT NULL
AND saved_gift.msg_id > 0
AND EXISTS (
SELECT 1
FROM public.message_boxes box
WHERE box.owner_user_id = saved_gift.owner_peer_id
AND box.box_id = saved_gift.msg_id
AND NOT box.deleted
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint = saved_gift.unique_gift_id
);
UPDATE public.peer_star_gifts
SET upgrade_msg_id = 0
WHERE owner_peer_type = 'channel'
AND unique_gift_id IS NOT NULL
AND upgrade_msg_id <> 0;
UPDATE public.unique_star_gifts
SET burned = true,
craft_chance_permille = 0,
offer_min_stars = 0,
updated_at = now()
WHERE burned;
UPDATE public.peer_star_gifts
SET lifecycle_status = 'burned',
unsaved = true,
pinned_order = 0,
transfer_stars = 0,
can_export_at = 0,
can_transfer_at = 0,
can_resell_at = 0,
drop_original_details_stars = 0,
can_craft_at = 0
WHERE lifecycle_status = 'burned';
FOR gift_box IN
SELECT box.owner_user_id,
box.box_id,
box.peer_type,
box.peer_id,
box.message_sender_id,
box.private_message_id,
box.media
FROM public.message_boxes box
JOIN public.unique_star_gifts unique_gift
ON unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint
WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND unique_gift.burned
AND (
COALESCE((box.media #>> '{service_action,star_gift_unique,gift,Burned}')::boolean, false) = false
OR COALESCE((box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}')::integer, 0) <> 0
OR box.media #> '{service_action,star_gift_unique,saved}' IS NOT NULL
OR box.media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
)
ORDER BY box.owner_user_id, box.box_id
LOOP
repaired_media := jsonb_set(
jsonb_set(
gift_box.media
#- '{service_action,star_gift_unique,gift,CraftChancePermille}'
#- '{service_action,star_gift_unique,saved}'
#- '{service_action,star_gift_unique,can_export_at}'
#- '{service_action,star_gift_unique,transfer_stars}'
#- '{service_action,star_gift_unique,resale_amount}'
#- '{service_action,star_gift_unique,can_transfer_at}'
#- '{service_action,star_gift_unique,can_resell_at}'
#- '{service_action,star_gift_unique,drop_original_details_stars}'
#- '{service_action,star_gift_unique,can_craft_at}',
'{service_action,star_gift_unique,gift,Burned}', 'true'::jsonb, true),
'{service_action,star_gift_unique,gift,OfferMinStars}', '0'::jsonb, true);
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
VALUES (gift_box.owner_user_id, 0)
ON CONFLICT (user_id) DO NOTHING;
UPDATE public.user_update_watermarks
SET contiguous_pts = contiguous_pts + 1,
updated_at = now()
WHERE user_id = gift_box.owner_user_id
RETURNING contiguous_pts INTO next_pts;
UPDATE public.message_boxes
SET media = repaired_media,
pts = next_pts
WHERE owner_user_id = gift_box.owner_user_id
AND box_id = gift_box.box_id
AND NOT deleted;
UPDATE public.private_messages
SET media = jsonb_set(
jsonb_set(
media
#- '{service_action,star_gift_unique,gift,CraftChancePermille}'
#- '{service_action,star_gift_unique,saved}'
#- '{service_action,star_gift_unique,can_export_at}'
#- '{service_action,star_gift_unique,transfer_stars}'
#- '{service_action,star_gift_unique,resale_amount}'
#- '{service_action,star_gift_unique,can_transfer_at}'
#- '{service_action,star_gift_unique,can_resell_at}'
#- '{service_action,star_gift_unique,drop_original_details_stars}'
#- '{service_action,star_gift_unique,can_craft_at}',
'{service_action,star_gift_unique,gift,Burned}', 'true'::jsonb, true),
'{service_action,star_gift_unique,gift,OfferMinStars}', '0'::jsonb, true),
sender_snapshot = jsonb_set(
jsonb_set(
sender_snapshot
#- '{message,Media,service_action,star_gift_unique,gift,CraftChancePermille}'
#- '{message,Media,service_action,star_gift_unique,saved}'
#- '{message,Media,service_action,star_gift_unique,can_export_at}'
#- '{message,Media,service_action,star_gift_unique,transfer_stars}'
#- '{message,Media,service_action,star_gift_unique,resale_amount}'
#- '{message,Media,service_action,star_gift_unique,can_transfer_at}'
#- '{message,Media,service_action,star_gift_unique,can_resell_at}'
#- '{message,Media,service_action,star_gift_unique,drop_original_details_stars}'
#- '{message,Media,service_action,star_gift_unique,can_craft_at}',
'{message,Media,service_action,star_gift_unique,gift,Burned}', 'true'::jsonb, true),
'{message,Media,service_action,star_gift_unique,gift,OfferMinStars}', '0'::jsonb, true)
WHERE sender_user_id = gift_box.message_sender_id
AND id = gift_box.private_message_id;
INSERT INTO public.user_update_events (
user_id, pts, pts_count, date, event_type,
message_box_id, peer_type, peer_id
) VALUES (
gift_box.owner_user_id, next_pts, 1, event_date, 'edit_message',
gift_box.box_id, gift_box.peer_type, gift_box.peer_id
);
INSERT INTO public.dispatch_outbox (
target_user_id, pts, event_type,
exclude_auth_key_id, exclude_session_id
) VALUES (
gift_box.owner_user_id, next_pts, 'edit_message', 0, 0
);
END LOOP;
UPDATE public.star_gift_craft_commands command
SET source_edit_pts = (
SELECT array_agg(box.pts ORDER BY input.ordinality)::integer[] AS pts
FROM unnest(command.input_unique_gift_ids) WITH ORDINALITY AS input(unique_gift_id, ordinality)
JOIN public.unique_star_gifts unique_gift ON unique_gift.id = input.unique_gift_id
JOIN public.peer_star_gifts saved_gift ON saved_gift.id = unique_gift.source_saved_gift_id
JOIN public.message_boxes box
ON box.owner_user_id = command.user_id
AND box.box_id = saved_gift.upgrade_msg_id
AND NOT box.deleted
)
WHERE NOT command.success;
IF EXISTS (
SELECT 1
FROM public.star_gift_craft_commands
WHERE cardinality(source_edit_pts) <> cardinality(input_unique_gift_ids)
OR array_position(source_edit_pts, 0) IS NOT NULL
) THEN
RAISE EXCEPTION 'craft command is missing an exact source message edit receipt';
END IF;
END
$$;
ALTER TABLE public.star_gift_craft_commands
ADD CONSTRAINT star_gift_craft_source_edit_shape_check CHECK (
cardinality(source_edit_pts) = cardinality(input_unique_gift_ids)
AND array_position(source_edit_pts, 0) IS NULL
);

View file

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS public.peer_star_gifts_owner_profile_order_idx;
DROP INDEX IF EXISTS public.peer_star_gifts_owner_pinned_order_uniq;

View file

@ -0,0 +1,13 @@
CREATE UNIQUE INDEX peer_star_gifts_owner_pinned_order_uniq
ON public.peer_star_gifts(owner_peer_type, owner_peer_id, pinned_order)
WHERE pinned_order > 0;
CREATE INDEX peer_star_gifts_owner_profile_order_idx
ON public.peer_star_gifts(
owner_peer_type,
owner_peer_id,
(pinned_order = 0),
pinned_order,
id DESC
)
WHERE lifecycle_status = 'active';

View file

@ -0,0 +1,25 @@
DROP TRIGGER IF EXISTS account_settings_ttl_trigger ON public.account_settings;
DROP FUNCTION IF EXISTS public.telesrv_account_settings_ttl_trigger();
DROP TRIGGER IF EXISTS users_account_delete_at_trigger ON public.users;
DROP FUNCTION IF EXISTS public.telesrv_users_account_delete_at_trigger();
DROP FUNCTION IF EXISTS public.telesrv_account_delete_at(timestamp with time zone, bigint, integer);
DROP TRIGGER IF EXISTS account_passwords_changed_at_trigger ON public.account_passwords;
DROP FUNCTION IF EXISTS public.telesrv_password_changed_at_trigger();
ALTER TABLE public.account_passwords DROP COLUMN IF EXISTS password_changed_at;
ALTER TABLE public.account_settings
DROP CONSTRAINT account_settings_account_ttl_days_check,
ADD CONSTRAINT account_settings_account_ttl_days_check CHECK (account_ttl_days > 0);
DROP TABLE IF EXISTS public.account_deletion_notifications;
DROP TABLE IF EXISTS public.account_deletion_requests;
DROP INDEX IF EXISTS public.dialogs_user_peer_reverse_idx;
DROP INDEX IF EXISTS public.users_account_delete_due_idx;
ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_deletion_state_check;
ALTER TABLE public.users
DROP COLUMN IF EXISTS account_delete_at,
DROP COLUMN IF EXISTS deletion_reason,
DROP COLUMN IF EXISTS deletion_source,
DROP COLUMN IF EXISTS deleted_at;

View file

@ -0,0 +1,216 @@
-- Unified account deletion lifecycle. A deleted account remains as a minimal
-- user tombstone so historical messages keep a stable sender id, while all
-- reusable identity and profile fields are released atomically.
ALTER TABLE public.users
ADD COLUMN deleted_at timestamp with time zone,
ADD COLUMN deletion_source text DEFAULT '' NOT NULL,
ADD COLUMN deletion_reason text DEFAULT '' NOT NULL,
ADD COLUMN account_delete_at timestamp with time zone;
ALTER TABLE public.account_passwords
ADD COLUMN password_changed_at timestamp with time zone;
ALTER TABLE public.account_settings
DROP CONSTRAINT account_settings_account_ttl_days_check,
ADD CONSTRAINT account_settings_account_ttl_days_check
CHECK (account_ttl_days BETWEEN 1 AND 3650);
CREATE OR REPLACE FUNCTION public.telesrv_password_changed_at_trigger()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
NEW.password_changed_at := CASE WHEN NEW.has_password THEN now() ELSE NULL END;
ELSIF NEW.has_password IS DISTINCT FROM OLD.has_password
OR NEW.srp_verifier IS DISTINCT FROM OLD.srp_verifier
OR NEW.current_algo_salt1 IS DISTINCT FROM OLD.current_algo_salt1
OR NEW.current_algo_salt2 IS DISTINCT FROM OLD.current_algo_salt2
OR NEW.current_algo_g IS DISTINCT FROM OLD.current_algo_g
OR NEW.current_algo_p IS DISTINCT FROM OLD.current_algo_p THEN
NEW.password_changed_at := CASE WHEN NEW.has_password THEN now() ELSE NULL END;
ELSE
NEW.password_changed_at := OLD.password_changed_at;
END IF;
RETURN NEW;
END
$$;
UPDATE public.account_passwords
SET password_changed_at = updated_at
WHERE has_password = true AND password_changed_at IS NULL;
CREATE TRIGGER account_passwords_changed_at_trigger
BEFORE INSERT OR UPDATE ON public.account_passwords
FOR EACH ROW EXECUTE FUNCTION public.telesrv_password_changed_at_trigger();
ALTER TABLE public.users
ADD CONSTRAINT users_deletion_state_check CHECK (
(deleted_at IS NULL AND deletion_source = '' AND deletion_reason = '')
OR
(deleted_at IS NOT NULL
AND deletion_source IN (
'manual',
'forgot_password',
'tos_decline',
'password_reset_expiry',
'account_ttl',
'freeze_expiry'
)
AND account_delete_at IS NULL
AND phone = ''
AND first_name = ''
AND last_name = ''
AND username = ''
AND country_code = ''
AND about = ''
AND verified = false
AND support = false
AND premium_expires_at IS NULL
AND emoji_status_document_id = 0
AND emoji_status_until = 0
AND color_set = false
AND color = 0
AND color_background_emoji_id = 0
AND profile_color_set = false
AND profile_color = 0
AND profile_color_background_emoji_id = 0
AND birthday_day = 0
AND birthday_month = 0
AND birthday_year = 0
AND personal_channel_id = 0
AND last_seen_at = 0
AND octet_length(deletion_reason) <= 1024)
);
CREATE INDEX users_account_delete_due_idx
ON public.users (account_delete_at, id)
WHERE deleted_at IS NULL AND is_bot = false AND account_delete_at IS NOT NULL;
CREATE TABLE public.account_deletion_requests (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES public.users(id),
requester_auth_key_id bigint NOT NULL,
state text DEFAULT 'pending' NOT NULL,
reason text DEFAULT '' NOT NULL,
confirm_hash_digest bytea NOT NULL,
requested_at timestamp with time zone DEFAULT now() NOT NULL,
execute_at timestamp with time zone NOT NULL,
completed_at timestamp with time zone,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT account_deletion_requests_state_check CHECK (
(state = 'pending' AND completed_at IS NULL)
OR (state IN ('cancelled', 'executed') AND completed_at IS NOT NULL)
),
CONSTRAINT account_deletion_requests_reason_check CHECK (octet_length(reason) <= 1024),
CONSTRAINT account_deletion_requests_hash_check CHECK (octet_length(confirm_hash_digest) = 32)
);
CREATE UNIQUE INDEX account_deletion_requests_one_pending_user_idx
ON public.account_deletion_requests(user_id) WHERE state = 'pending';
CREATE UNIQUE INDEX account_deletion_requests_pending_hash_idx
ON public.account_deletion_requests(confirm_hash_digest) WHERE state = 'pending';
CREATE INDEX account_deletion_requests_due_idx
ON public.account_deletion_requests(execute_at, id) WHERE state = 'pending';
-- updateUser is not a pts-bearing update. Keep a dedicated durable online-nudge
-- queue so a crash between tombstone commit and best-effort fan-out is recovered.
-- Offline clients converge from authoritative dialog/history hydration instead
-- of an immortal retry queue or invented user pts events.
CREATE TABLE public.account_deletion_notifications (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
target_user_id bigint NOT NULL REFERENCES public.users(id),
deleted_user_id bigint NOT NULL REFERENCES public.users(id),
status text DEFAULT 'pending' NOT NULL,
attempts integer DEFAULT 0 NOT NULL,
next_attempt_at timestamp with time zone DEFAULT now() NOT NULL,
lease_until timestamp with time zone,
last_error text DEFAULT '' NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT account_deletion_notifications_status_check
CHECK (status IN ('pending', 'dispatching', 'delivered')),
CONSTRAINT account_deletion_notifications_attempts_check CHECK (attempts >= 0),
CONSTRAINT account_deletion_notifications_not_self_check CHECK (target_user_id <> deleted_user_id),
UNIQUE (target_user_id, deleted_user_id)
);
CREATE INDEX account_deletion_notifications_ready_idx
ON public.account_deletion_notifications(next_attempt_at, id)
WHERE status = 'pending';
-- Account deletion discovers peers that have an inbound dialog row pointing at
-- the deleted user. The ordinary dialog PK only covers the owner direction;
-- keep the reverse lookup indexed so tombstoning one account never scans every
-- user's dialog table.
CREATE INDEX dialogs_user_peer_reverse_idx
ON public.dialogs(peer_id, user_id)
WHERE peer_type = 'user';
CREATE OR REPLACE FUNCTION public.telesrv_account_delete_at(
p_created_at timestamp with time zone,
p_last_seen_at bigint,
p_ttl_days integer
) RETURNS timestamp with time zone
LANGUAGE sql IMMUTABLE AS $$
SELECT GREATEST(
p_created_at,
CASE WHEN p_last_seen_at > 0 THEN to_timestamp(p_last_seen_at) ELSE p_created_at END
) + make_interval(days => p_ttl_days)
$$;
CREATE OR REPLACE FUNCTION public.telesrv_users_account_delete_at_trigger()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
v_ttl_days integer;
BEGIN
IF NEW.deleted_at IS NOT NULL OR NEW.is_bot THEN
NEW.account_delete_at := NULL;
RETURN NEW;
END IF;
SELECT account_ttl_days INTO v_ttl_days
FROM public.account_settings WHERE user_id = NEW.id;
NEW.account_delete_at := public.telesrv_account_delete_at(
NEW.created_at,
NEW.last_seen_at,
COALESCE(v_ttl_days, 365)
);
RETURN NEW;
END
$$;
CREATE TRIGGER users_account_delete_at_trigger
BEFORE INSERT OR UPDATE OF last_seen_at, deleted_at, is_bot
ON public.users
FOR EACH ROW EXECUTE FUNCTION public.telesrv_users_account_delete_at_trigger();
CREATE OR REPLACE FUNCTION public.telesrv_account_settings_ttl_trigger()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
v_user_id bigint;
v_ttl_days integer;
BEGIN
v_user_id := COALESCE(NEW.user_id, OLD.user_id);
v_ttl_days := CASE WHEN TG_OP = 'DELETE' THEN 365 ELSE NEW.account_ttl_days END;
UPDATE public.users
SET account_delete_at = public.telesrv_account_delete_at(created_at, last_seen_at, v_ttl_days),
updated_at = now()
WHERE id = v_user_id AND deleted_at IS NULL AND is_bot = false;
RETURN COALESCE(NEW, OLD);
END
$$;
CREATE TRIGGER account_settings_ttl_trigger
AFTER INSERT OR UPDATE OF account_ttl_days OR DELETE
ON public.account_settings
FOR EACH ROW EXECUTE FUNCTION public.telesrv_account_settings_ttl_trigger();
UPDATE public.users u
SET account_delete_at = public.telesrv_account_delete_at(
u.created_at,
u.last_seen_at,
COALESCE((
SELECT s.account_ttl_days
FROM public.account_settings s
WHERE s.user_id = u.id
), 365)
)
WHERE u.deleted_at IS NULL AND u.is_bot = false;

View file

@ -0,0 +1 @@
ALTER TABLE channel_messages DROP COLUMN IF EXISTS suggested_post;

View file

@ -0,0 +1,98 @@
ALTER TABLE channel_messages
ADD COLUMN suggested_post jsonb NOT NULL DEFAULT '{}'::jsonb;
-- A monoforum is a virtual per-saved-peer container, never an ordinary joined megagroup.
DELETE FROM channel_dialogs d
USING channels c
WHERE d.channel_id = c.id AND c.monoforum;
DELETE FROM user_channel_member_index i
USING channels c
WHERE i.channel_id = c.id AND c.monoforum;
DELETE FROM channel_members m
USING channels c
WHERE m.channel_id = c.id AND c.monoforum;
UPDATE channels c
SET participants_count = 0,
admins_count = 0,
updated_at = now()
WHERE c.monoforum AND (c.participants_count <> 0 OR c.admins_count <> 0);
-- Older generic sends from non-admin users are deterministically their own saved-peer dialog.
UPDATE channel_messages m
SET saved_peer_type = 'user',
saved_peer_id = m.sender_user_id
FROM channels mono
WHERE mono.id = m.channel_id
AND mono.monoforum
AND NOT m.deleted
AND m.saved_peer_id = 0
AND m.action = '{}'::jsonb
AND m.sender_user_id <> 0
AND NOT EXISTS (
SELECT 1
FROM channel_members parent_member
WHERE parent_member.channel_id = mono.linked_monoforum_id
AND parent_member.user_id = m.sender_user_id
AND parent_member.status = 'active'
AND parent_member.role IN ('creator', 'admin')
);
UPDATE channel_update_events e
SET payload = jsonb_set(
e.payload,
'{message,SavedPeer}',
jsonb_build_object('Type', 'user', 'ID', m.sender_user_id),
true
)
FROM channel_messages m, channels mono
WHERE mono.id = m.channel_id
AND mono.monoforum
AND e.channel_id = m.channel_id
AND e.message_id = m.id
AND m.saved_peer_type = 'user'
AND m.saved_peer_id = m.sender_user_id
AND COALESCE((e.payload #>> '{message,SavedPeer,ID}')::bigint, 0) = 0;
UPDATE channel_messages m
SET send_snapshot = jsonb_set(
m.send_snapshot,
'{message,SavedPeer}',
jsonb_build_object('Type', 'user', 'ID', m.sender_user_id),
true
)
FROM channels mono
WHERE mono.id = m.channel_id
AND mono.monoforum
AND m.random_id <> 0
AND m.saved_peer_type = 'user'
AND m.saved_peer_id = m.sender_user_id
AND COALESCE((m.send_snapshot #>> '{message,SavedPeer,ID}')::bigint, 0) = 0;
-- Remove the impossible join service message without creating a pts gap: retain the event row as noop.
UPDATE channel_update_events e
SET event_type = 'noop', message_id = 0, sender_user_id = 0, user_ids = '[]'::jsonb, payload = '{}'::jsonb
FROM channel_messages m, channels mono
WHERE mono.id = m.channel_id
AND mono.monoforum
AND e.channel_id = m.channel_id
AND e.message_id = m.id
AND m.action->>'Type' = 'chat_joined';
UPDATE channel_messages m
SET deleted = true
FROM channels mono
WHERE mono.id = m.channel_id
AND mono.monoforum
AND m.action->>'Type' = 'chat_joined';
UPDATE channels mono
SET top_message_id = COALESCE((
SELECT max(m.id)
FROM channel_messages m
WHERE m.channel_id = mono.id AND NOT m.deleted
), 0),
updated_at = now()
WHERE mono.monoforum;

View file

@ -0,0 +1,3 @@
ALTER TABLE public.channel_messages
DROP CONSTRAINT IF EXISTS channel_messages_paid_message_stars_check,
DROP COLUMN IF EXISTS paid_message_stars;

View file

@ -0,0 +1,3 @@
ALTER TABLE public.channel_messages
ADD COLUMN paid_message_stars bigint NOT NULL DEFAULT 0,
ADD CONSTRAINT channel_messages_paid_message_stars_check CHECK (paid_message_stars >= 0);

View file

@ -0,0 +1,20 @@
DELETE FROM public.bot_api_updates WHERE update_kind = 'callback_query';
DROP INDEX IF EXISTS public.bot_api_updates_callback_query_unique;
DROP INDEX IF EXISTS public.bot_api_updates_message_source_unique;
ALTER TABLE public.bot_api_updates
DROP CONSTRAINT bot_api_updates_callback_shape_check,
DROP CONSTRAINT bot_api_updates_kind_check;
ALTER TABLE public.bot_api_updates
ADD CONSTRAINT bot_api_updates_kind_check
CHECK ((update_kind)::text = ANY (ARRAY['message'::text, 'edited_message'::text])),
ADD CONSTRAINT bot_api_updates_source_unique
UNIQUE (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts);
ALTER TABLE public.bot_api_updates
DROP COLUMN callback_data,
DROP COLUMN callback_chat_instance,
DROP COLUMN callback_user_id,
DROP COLUMN callback_query_id;

View file

@ -0,0 +1,35 @@
ALTER TABLE public.bot_api_updates
ADD COLUMN callback_query_id bigint NOT NULL DEFAULT 0,
ADD COLUMN callback_user_id bigint NOT NULL DEFAULT 0,
ADD COLUMN callback_chat_instance bigint NOT NULL DEFAULT 0,
ADD COLUMN callback_data bytea;
ALTER TABLE public.bot_api_updates
DROP CONSTRAINT bot_api_updates_kind_check,
DROP CONSTRAINT bot_api_updates_source_unique;
ALTER TABLE public.bot_api_updates
ADD CONSTRAINT bot_api_updates_kind_check
CHECK ((update_kind)::text = ANY (ARRAY['message'::text, 'edited_message'::text, 'callback_query'::text])),
ADD CONSTRAINT bot_api_updates_callback_shape_check CHECK (
(update_kind = 'callback_query'
AND callback_query_id <> 0
AND callback_user_id > 0
AND callback_chat_instance <> 0
AND COALESCE(octet_length(callback_data), 0) <= 64
AND source_pts = 0)
OR
(update_kind IN ('message', 'edited_message')
AND callback_query_id = 0
AND callback_user_id = 0
AND callback_chat_instance = 0
AND callback_data IS NULL)
);
CREATE UNIQUE INDEX bot_api_updates_message_source_unique
ON public.bot_api_updates (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts)
WHERE update_kind IN ('message', 'edited_message');
CREATE UNIQUE INDEX bot_api_updates_callback_query_unique
ON public.bot_api_updates (bot_user_id, callback_query_id)
WHERE update_kind = 'callback_query';

View file

@ -0,0 +1,6 @@
DROP INDEX IF EXISTS public.bot_api_updates_created_retention_idx;
ALTER TABLE public.bot_api_update_states
DROP CONSTRAINT IF EXISTS bot_api_update_states_allowed_updates_check,
DROP COLUMN IF EXISTS cursor_initialized,
DROP COLUMN IF EXISTS allowed_updates;

View file

@ -0,0 +1,12 @@
ALTER TABLE public.bot_api_update_states
ADD COLUMN allowed_updates text[],
ADD COLUMN cursor_initialized boolean NOT NULL DEFAULT false;
ALTER TABLE public.bot_api_update_states
ADD CONSTRAINT bot_api_update_states_allowed_updates_check CHECK (
allowed_updates IS NULL
OR array_position(allowed_updates, NULL) IS NULL
);
CREATE INDEX bot_api_updates_created_retention_idx
ON public.bot_api_updates (created_at, id);

View file

@ -0,0 +1,4 @@
ALTER TABLE public.bot_api_update_states
DROP CONSTRAINT IF EXISTS bot_api_update_states_poll_lease_check,
DROP COLUMN IF EXISTS poll_expires_at,
DROP COLUMN IF EXISTS poll_owner;

View file

@ -0,0 +1,10 @@
ALTER TABLE public.bot_api_update_states
ADD COLUMN poll_owner text NOT NULL DEFAULT '',
ADD COLUMN poll_expires_at timestamptz;
ALTER TABLE public.bot_api_update_states
ADD CONSTRAINT bot_api_update_states_poll_lease_check CHECK (
(poll_owner = '' AND poll_expires_at IS NULL)
OR
(poll_owner <> '' AND poll_expires_at IS NOT NULL)
);

View file

@ -0,0 +1,27 @@
DELETE FROM public.bot_api_updates
WHERE update_kind = 'callback_query' AND callback_inline_message_id <> 0;
ALTER TABLE public.bot_api_updates
DROP CONSTRAINT bot_api_updates_callback_shape_check,
DROP CONSTRAINT bot_api_updates_peer_type_check,
DROP CONSTRAINT bot_api_updates_peer_id_check,
DROP CONSTRAINT bot_api_updates_message_id_check;
ALTER TABLE public.bot_api_updates
ADD CONSTRAINT bot_api_updates_peer_type_check CHECK (peer_type IN ('user', 'channel')),
ADD CONSTRAINT bot_api_updates_peer_id_check CHECK (peer_id > 0),
ADD CONSTRAINT bot_api_updates_message_id_check CHECK (message_id > 0),
ADD CONSTRAINT bot_api_updates_callback_shape_check CHECK (
(update_kind = 'callback_query' AND callback_query_id <> 0 AND callback_user_id > 0
AND callback_chat_instance <> 0 AND COALESCE(octet_length(callback_data), 0) <= 64
AND source_pts = 0)
OR
(update_kind IN ('message', 'edited_message') AND callback_query_id = 0
AND callback_user_id = 0 AND callback_chat_instance = 0 AND callback_data IS NULL)
);
ALTER TABLE public.bot_api_updates
DROP COLUMN callback_inline_access_hash,
DROP COLUMN callback_inline_message_id,
DROP COLUMN callback_inline_owner_id,
DROP COLUMN callback_inline_dc_id;

View file

@ -0,0 +1,49 @@
ALTER TABLE public.bot_api_updates
ADD COLUMN callback_inline_dc_id integer NOT NULL DEFAULT 0,
ADD COLUMN callback_inline_owner_id bigint NOT NULL DEFAULT 0,
ADD COLUMN callback_inline_message_id integer NOT NULL DEFAULT 0,
ADD COLUMN callback_inline_access_hash bigint NOT NULL DEFAULT 0;
ALTER TABLE public.bot_api_updates
DROP CONSTRAINT bot_api_updates_callback_shape_check,
DROP CONSTRAINT bot_api_updates_peer_type_check,
DROP CONSTRAINT bot_api_updates_peer_id_check,
DROP CONSTRAINT bot_api_updates_message_id_check;
ALTER TABLE public.bot_api_updates
ADD CONSTRAINT bot_api_updates_peer_type_check CHECK (
peer_type IN ('user', 'channel')
OR (update_kind = 'callback_query' AND peer_type = '')
),
ADD CONSTRAINT bot_api_updates_peer_id_check CHECK (
peer_id > 0
OR (update_kind = 'callback_query' AND peer_id = 0)
),
ADD CONSTRAINT bot_api_updates_message_id_check CHECK (
message_id > 0
OR (update_kind = 'callback_query' AND message_id = 0)
),
ADD CONSTRAINT bot_api_updates_callback_shape_check CHECK (
(update_kind = 'callback_query'
AND callback_query_id <> 0
AND callback_user_id > 0
AND callback_chat_instance <> 0
AND COALESCE(octet_length(callback_data), 0) <= 64
AND source_pts = 0
AND (
(peer_type IN ('user', 'channel') AND peer_id > 0 AND message_id > 0
AND callback_inline_dc_id = 0 AND callback_inline_owner_id = 0
AND callback_inline_message_id = 0 AND callback_inline_access_hash = 0)
OR
(peer_type = '' AND peer_id = 0 AND message_id = 0
AND callback_inline_dc_id > 0 AND callback_inline_owner_id > 0
AND callback_inline_message_id > 0 AND callback_inline_access_hash <> 0)
))
OR
(update_kind IN ('message', 'edited_message')
AND peer_type IN ('user', 'channel') AND peer_id > 0 AND message_id > 0
AND callback_query_id = 0 AND callback_user_id = 0
AND callback_chat_instance = 0 AND callback_data IS NULL
AND callback_inline_dc_id = 0 AND callback_inline_owner_id = 0
AND callback_inline_message_id = 0 AND callback_inline_access_hash = 0)
);

View file

@ -0,0 +1,61 @@
DROP TRIGGER IF EXISTS unique_star_gifts_clear_invalid_emoji_status ON public.unique_star_gifts;
DROP FUNCTION IF EXISTS public.telesrv_clear_invalid_collectible_emoji_status();
UPDATE public.documents d
SET attributes = COALESCE((
SELECT jsonb_agg(
CASE
WHEN item->>'kind' = 'custom_emoji' AND COALESCE((item->>'text_color')::boolean, false) THEN
(item - 'text_color') || jsonb_build_object('kind', 'sticker')
ELSE item
END
ORDER BY ord
)
FROM jsonb_array_elements(d.attributes) WITH ORDINALITY AS attrs(item, ord)
), '[]'::jsonb)
WHERE d.id IN (SELECT document_id FROM public.star_gift_collectible_patterns);
ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check;
ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK (
(event_type)::text = ANY (ARRAY[
'new_message', 'read_history_inbox', 'read_history_outbox', 'read_message_contents',
'edit_message', 'web_page', 'message_reactions', 'message_poll', 'draft_message', 'quick_replies',
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'delete_messages', 'dialog_filter',
'dialog_filter_order', 'dialog_filters', 'folder_peers', 'channel_available_messages',
'channel_view_forum_as_messages', 'channel_state', 'saved_dialog_pinned',
'pinned_saved_dialogs', 'story', 'read_stories', 'sent_story_reaction',
'new_story_reaction', 'noop', 'read_channel_discussion_inbox',
'read_channel_discussion_outbox'
]::text[])
);
ALTER TABLE public.user_update_events DROP COLUMN IF EXISTS emoji_status_payload;
ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_deletion_state_check;
ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_emoji_status_shape_check;
ALTER TABLE public.users
DROP COLUMN IF EXISTS emoji_status_collectible,
DROP COLUMN IF EXISTS emoji_status_collectible_id;
ALTER TABLE public.users
ADD CONSTRAINT users_deletion_state_check CHECK (
(deleted_at IS NULL AND deletion_source = '' AND deletion_reason = '')
OR
(deleted_at IS NOT NULL
AND deletion_source IN (
'manual', 'forgot_password', 'tos_decline', 'password_reset_expiry',
'account_ttl', 'freeze_expiry'
)
AND account_delete_at IS NULL
AND phone = '' AND first_name = '' AND last_name = '' AND username = ''
AND country_code = '' AND about = '' AND verified = false AND support = false
AND premium_expires_at IS NULL
AND emoji_status_document_id = 0 AND emoji_status_until = 0
AND color_set = false AND color = 0 AND color_background_emoji_id = 0
AND profile_color_set = false AND profile_color = 0
AND profile_color_background_emoji_id = 0
AND birthday_day = 0 AND birthday_month = 0 AND birthday_year = 0
AND personal_channel_id = 0 AND last_seen_at = 0
AND octet_length(deletion_reason) <= 1024)
);

View file

@ -0,0 +1,166 @@
-- Complete collectible emoji-status state: users persist the selected unique
-- gift plus an immutable render snapshot, and the account update log persists
-- the exact status payload for online dispatch/offline difference replay.
ALTER TABLE public.users
ADD COLUMN emoji_status_collectible_id bigint REFERENCES public.unique_star_gifts(id),
ADD COLUMN emoji_status_collectible jsonb DEFAULT '{}'::jsonb NOT NULL;
ALTER TABLE public.users
ADD CONSTRAINT users_emoji_status_shape_check CHECK (
emoji_status_document_id >= 0
AND emoji_status_until >= 0
AND (emoji_status_document_id > 0 OR emoji_status_until = 0)
AND (
(
emoji_status_collectible_id IS NULL
AND emoji_status_collectible = '{}'::jsonb
) OR (
emoji_status_collectible_id IS NOT NULL
AND emoji_status_collectible_id > 0
AND emoji_status_document_id > 0
AND jsonb_typeof(emoji_status_collectible) = 'object'
AND emoji_status_collectible <> '{}'::jsonb
AND emoji_status_collectible ? 'collectible_id'
AND emoji_status_collectible ? 'document_id'
AND emoji_status_collectible ? 'title'
AND emoji_status_collectible ? 'slug'
AND emoji_status_collectible ? 'pattern_document_id'
AND (emoji_status_collectible->>'collectible_id')::bigint = emoji_status_collectible_id
AND (emoji_status_collectible->>'document_id')::bigint = emoji_status_document_id
AND (emoji_status_collectible->>'pattern_document_id')::bigint > 0
AND length(emoji_status_collectible->>'title') > 0
AND length(emoji_status_collectible->>'slug') > 0
AND (emoji_status_collectible->>'center_color')::integer BETWEEN 0 AND 16777215
AND (emoji_status_collectible->>'edge_color')::integer BETWEEN 0 AND 16777215
AND (emoji_status_collectible->>'pattern_color')::integer BETWEEN 0 AND 16777215
AND (emoji_status_collectible->>'text_color')::integer BETWEEN 0 AND 16777215
)
)
);
ALTER TABLE public.user_update_events
ADD COLUMN emoji_status_payload jsonb DEFAULT '{}'::jsonb NOT NULL;
ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check;
ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK (
(event_type)::text = ANY (ARRAY[
'new_message', 'read_history_inbox', 'read_history_outbox', 'read_message_contents',
'edit_message', 'web_page', 'message_reactions', 'message_poll', 'draft_message', 'quick_replies',
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'delete_messages',
'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers',
'channel_available_messages', 'channel_view_forum_as_messages', 'channel_state',
'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox'
]::text[])
);
-- Android applies the collectible backdrop's pattern_color only to a
-- documentAttributeCustomEmoji with text_color=true. Existing imports stored
-- these pattern documents as ordinary stickers, so repair them in place.
UPDATE public.documents d
SET attributes = COALESCE((
SELECT jsonb_agg(
CASE
WHEN item->>'kind' = 'sticker' THEN
jsonb_set(
jsonb_set(item, '{kind}', '"custom_emoji"'::jsonb, false),
'{text_color}', 'true'::jsonb, true
)
ELSE item
END
ORDER BY ord
)
FROM jsonb_array_elements(d.attributes) WITH ORDINALITY AS attrs(item, ord)
), '[]'::jsonb)
WHERE d.id IN (SELECT document_id FROM public.star_gift_collectible_patterns);
-- A transferred/exported/burned gift can no longer remain as the previous
-- owner's status. Keep the durable user state valid even if the lifecycle
-- mutation did not originate from an account RPC.
CREATE OR REPLACE FUNCTION public.telesrv_clear_invalid_collectible_emoji_status()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
cleared_user_id bigint;
cleared_pts integer;
event_date integer;
BEGIN
event_date := EXTRACT(EPOCH FROM clock_timestamp())::integer;
FOR cleared_user_id IN
UPDATE public.users u
SET emoji_status_document_id = 0,
emoji_status_until = 0,
emoji_status_collectible_id = NULL,
emoji_status_collectible = '{}'::jsonb,
updated_at = now()
WHERE u.emoji_status_collectible_id = NEW.id
AND (
NEW.burned
OR NEW.owner_address <> ''
OR NEW.owner_peer_type IS DISTINCT FROM 'user'
OR NEW.owner_peer_id IS DISTINCT FROM u.id
)
RETURNING u.id
LOOP
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
VALUES (cleared_user_id, 0)
ON CONFLICT (user_id) DO NOTHING;
UPDATE public.user_update_watermarks
SET contiguous_pts = contiguous_pts + 1,
updated_at = now()
WHERE user_id = cleared_user_id
RETURNING contiguous_pts INTO cleared_pts;
INSERT INTO public.user_update_events (
user_id, pts, pts_count, date, event_type,
peer_type, peer_id, emoji_status_payload
) VALUES (
cleared_user_id, cleared_pts, 1, event_date, 'user_emoji_status',
'user', cleared_user_id, '{}'::jsonb
);
-- No session is the origin of a lifecycle invalidation: every online
-- device receives it, and offline devices replay the same event.
INSERT INTO public.dispatch_outbox (
target_user_id, pts, event_type, exclude_auth_key_id, exclude_session_id
) VALUES (
cleared_user_id, cleared_pts, 'user_emoji_status', 0, 0
);
END LOOP;
RETURN NEW;
END
$$;
CREATE TRIGGER unique_star_gifts_clear_invalid_emoji_status
AFTER UPDATE OF owner_peer_type, owner_peer_id, owner_address, burned
ON public.unique_star_gifts
FOR EACH ROW EXECUTE FUNCTION public.telesrv_clear_invalid_collectible_emoji_status();
-- Deleted-user tombstones must not retain the new collectible facts.
ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_deletion_state_check;
ALTER TABLE public.users
ADD CONSTRAINT users_deletion_state_check CHECK (
(deleted_at IS NULL AND deletion_source = '' AND deletion_reason = '')
OR
(deleted_at IS NOT NULL
AND deletion_source IN (
'manual', 'forgot_password', 'tos_decline', 'password_reset_expiry',
'account_ttl', 'freeze_expiry'
)
AND account_delete_at IS NULL
AND phone = '' AND first_name = '' AND last_name = '' AND username = ''
AND country_code = '' AND about = '' AND verified = false AND support = false
AND premium_expires_at IS NULL
AND emoji_status_document_id = 0 AND emoji_status_until = 0
AND emoji_status_collectible_id IS NULL
AND emoji_status_collectible = '{}'::jsonb
AND color_set = false AND color = 0 AND color_background_emoji_id = 0
AND profile_color_set = false AND profile_color = 0
AND profile_color_background_emoji_id = 0
AND birthday_day = 0 AND birthday_month = 0 AND birthday_year = 0
AND personal_channel_id = 0 AND last_seen_at = 0
AND octet_length(deletion_reason) <= 1024)
);

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.bot_api_webhooks;

View file

@ -0,0 +1,27 @@
CREATE TABLE public.bot_api_webhooks (
bot_user_id bigint PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE,
url text NOT NULL,
secret_token varchar(256) NOT NULL DEFAULT '',
max_connections integer NOT NULL DEFAULT 40,
allowed_updates text[],
failure_count integer NOT NULL DEFAULT 0,
last_error_date integer NOT NULL DEFAULT 0,
last_error_message varchar(512) NOT NULL DEFAULT '',
next_attempt_at timestamptz NOT NULL DEFAULT now(),
delivery_owner text NOT NULL DEFAULT '',
delivery_expires_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT bot_api_webhooks_url_check CHECK (length(url) BETWEEN 1 AND 2048),
CONSTRAINT bot_api_webhooks_max_connections_check CHECK (max_connections BETWEEN 1 AND 100),
CONSTRAINT bot_api_webhooks_failure_count_check CHECK (failure_count >= 0),
CONSTRAINT bot_api_webhooks_allowed_updates_check CHECK (
allowed_updates IS NULL OR array_position(allowed_updates, NULL) IS NULL
),
CONSTRAINT bot_api_webhooks_delivery_lease_check CHECK (
(delivery_owner = '' AND delivery_expires_at IS NULL)
OR (delivery_owner <> '' AND delivery_expires_at IS NOT NULL)
)
);
CREATE INDEX bot_api_webhooks_due_idx
ON public.bot_api_webhooks (next_attempt_at, bot_user_id);

View file

@ -0,0 +1,2 @@
ALTER TABLE public.webview_requested_buttons
DROP COLUMN IF EXISTS peer_filter;

View file

@ -0,0 +1,2 @@
ALTER TABLE public.webview_requested_buttons
ADD COLUMN peer_filter jsonb NOT NULL DEFAULT '{}'::jsonb;

View file

@ -0,0 +1,98 @@
CREATE TEMP TABLE telesrv_pattern_document_repair_rollback ON COMMIT DROP AS
SELECT old_document_id, new_document_id
FROM public.star_gift_pattern_document_repairs;
CREATE TEMP TABLE telesrv_rollback_collectible_wearers ON COMMIT DROP AS
SELECT u.id AS user_id, r.old_document_id, r.new_document_id
FROM public.users u
JOIN telesrv_pattern_document_repair_rollback r
ON (u.emoji_status_collectible->>'pattern_document_id')::bigint = r.new_document_id
WHERE u.emoji_status_collectible_id IS NOT NULL;
UPDATE public.users u
SET emoji_status_collectible = jsonb_set(
u.emoji_status_collectible,
'{pattern_document_id}',
to_jsonb(w.old_document_id),
false
),
updated_at = now()
FROM telesrv_rollback_collectible_wearers w
WHERE u.id = w.user_id;
UPDATE public.user_update_events e
SET emoji_status_payload = jsonb_set(
e.emoji_status_payload,
'{collectible,pattern_document_id}',
to_jsonb(r.old_document_id),
false
)
FROM telesrv_pattern_document_repair_rollback r
WHERE e.event_type = 'user_emoji_status'
AND (e.emoji_status_payload #>> '{collectible,pattern_document_id}')::bigint = r.new_document_id;
ALTER TABLE public.star_gift_collectible_patterns
DISABLE TRIGGER star_gift_collectible_pattern_guard;
UPDATE public.star_gift_collectible_patterns p
SET document_id = r.old_document_id
FROM telesrv_pattern_document_repair_rollback r
WHERE p.document_id = r.new_document_id;
ALTER TABLE public.star_gift_collectible_patterns
ENABLE TRIGGER star_gift_collectible_pattern_guard;
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
SELECT user_id, 0 FROM telesrv_rollback_collectible_wearers
ON CONFLICT (user_id) DO NOTHING;
CREATE TEMP TABLE telesrv_collectible_pattern_rollback_events (
user_id bigint PRIMARY KEY,
pts integer NOT NULL
) ON COMMIT DROP;
WITH bumped AS (
UPDATE public.user_update_watermarks w
SET contiguous_pts = contiguous_pts + 1,
updated_at = now()
FROM telesrv_rollback_collectible_wearers wearer
WHERE w.user_id = wearer.user_id
RETURNING w.user_id, w.contiguous_pts
)
INSERT INTO telesrv_collectible_pattern_rollback_events (user_id, pts)
SELECT user_id, contiguous_pts FROM bumped;
INSERT INTO public.user_update_events (
user_id, pts, pts_count, date, event_type,
peer_type, peer_id, emoji_status_payload
)
SELECT
c.user_id,
c.pts,
1,
EXTRACT(EPOCH FROM clock_timestamp())::integer,
'user_emoji_status',
'user',
c.user_id,
jsonb_strip_nulls(jsonb_build_object(
'document_id', u.emoji_status_document_id,
'until', CASE WHEN u.emoji_status_until > 0 THEN u.emoji_status_until ELSE NULL END,
'collectible', u.emoji_status_collectible
))
FROM telesrv_collectible_pattern_rollback_events c
JOIN public.users u ON u.id = c.user_id;
INSERT INTO public.dispatch_outbox (
target_user_id, pts, event_type, exclude_auth_key_id, exclude_session_id
)
SELECT user_id, pts, 'user_emoji_status', 0, 0
FROM telesrv_collectible_pattern_rollback_events;
DELETE FROM public.file_blobs f
USING telesrv_pattern_document_repair_rollback r
WHERE f.location_key = 'doc:' || r.new_document_id::text
OR f.location_key LIKE 'doc:' || r.new_document_id::text || ':%';
DROP TABLE public.star_gift_pattern_document_repairs;
DELETE FROM public.documents d
USING telesrv_pattern_document_repair_rollback r
WHERE d.id = r.new_document_id;

View file

@ -0,0 +1,172 @@
-- Telegram clients cache Document metadata by id indefinitely. Migration 0114
-- corrected collectible pattern attributes in place, but an Android client that
-- had already cached the old sticker shape would never observe that correction.
-- Allocate a new immutable document identity for every pre-0117 pattern, keep a
-- durable old/new map for audit and rollback, and point the published attribute
-- at the clone. The blob bytes do not change, so the new location keys alias the
-- same immutable backend object.
CREATE TABLE public.star_gift_pattern_document_repairs (
old_document_id bigint PRIMARY KEY,
new_document_id bigint UNIQUE NOT NULL,
repaired_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT star_gift_pattern_document_repairs_ids_check CHECK (
old_document_id > 0 AND new_document_id > 0 AND old_document_id <> new_document_id
)
);
INSERT INTO public.star_gift_pattern_document_repairs (old_document_id, new_document_id)
SELECT DISTINCT
p.document_id,
((('x' || substr(md5(p.document_id::text || ':collectible-pattern-custom-emoji:v1'), 1, 16))
::bit(64)::bigint) & 9223372036854775807)
FROM public.star_gift_collectible_patterns p;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM public.star_gift_pattern_document_repairs r
JOIN public.documents d ON d.id = r.new_document_id
) THEN
RAISE EXCEPTION 'collectible pattern document repair id collision';
END IF;
END
$$;
INSERT INTO public.documents (
id, access_hash, file_reference, date, mime_type, size, dc_id,
attributes, thumbs, created_at
)
SELECT
r.new_document_id,
d.access_hash,
d.file_reference,
d.date,
d.mime_type,
d.size,
d.dc_id,
COALESCE((
SELECT jsonb_agg(
CASE
WHEN item->>'kind' IN ('sticker', 'custom_emoji') THEN
jsonb_set(
jsonb_set(item, '{kind}', '"custom_emoji"'::jsonb, false),
'{text_color}', 'true'::jsonb, true
)
ELSE item
END
ORDER BY ord
)
FROM jsonb_array_elements(d.attributes) WITH ORDINALITY AS attrs(item, ord)
), '[]'::jsonb),
d.thumbs,
now()
FROM public.star_gift_pattern_document_repairs r
JOIN public.documents d ON d.id = r.old_document_id;
INSERT INTO public.file_blobs (
location_key, backend, object_key, size, sha256, mime_type, created_at
)
SELECT
'doc:' || r.new_document_id::text ||
substr(f.location_key, length('doc:' || r.old_document_id::text) + 1),
f.backend,
f.object_key,
f.size,
f.sha256,
f.mime_type,
now()
FROM public.star_gift_pattern_document_repairs r
JOIN public.file_blobs f
ON f.location_key = 'doc:' || r.old_document_id::text
OR f.location_key LIKE 'doc:' || r.old_document_id::text || ':%';
-- This is a repair of a previously invalid document identity, not a mutation of
-- the published appearance. Attribute id/name/animation/rarity remain intact.
ALTER TABLE public.star_gift_collectible_patterns
DISABLE TRIGGER star_gift_collectible_pattern_guard;
UPDATE public.star_gift_collectible_patterns p
SET document_id = r.new_document_id
FROM public.star_gift_pattern_document_repairs r
WHERE p.document_id = r.old_document_id;
ALTER TABLE public.star_gift_collectible_patterns
ENABLE TRIGGER star_gift_collectible_pattern_guard;
-- Capture active wearers before rewriting their immutable render snapshots.
CREATE TEMP TABLE telesrv_repaired_collectible_wearers ON COMMIT DROP AS
SELECT u.id AS user_id, r.old_document_id, r.new_document_id
FROM public.users u
JOIN public.star_gift_pattern_document_repairs r
ON (u.emoji_status_collectible->>'pattern_document_id')::bigint = r.old_document_id
WHERE u.emoji_status_collectible_id IS NOT NULL;
UPDATE public.users u
SET emoji_status_collectible = jsonb_set(
u.emoji_status_collectible,
'{pattern_document_id}',
to_jsonb(w.new_document_id),
false
),
updated_at = now()
FROM telesrv_repaired_collectible_wearers w
WHERE u.id = w.user_id;
-- An offline client may still replay an older status event, so repair every
-- durable snapshot. A fresh event is appended below for clients that already
-- consumed the old pts and therefore need a new convergence edge.
UPDATE public.user_update_events e
SET emoji_status_payload = jsonb_set(
e.emoji_status_payload,
'{collectible,pattern_document_id}',
to_jsonb(r.new_document_id),
false
)
FROM public.star_gift_pattern_document_repairs r
WHERE e.event_type = 'user_emoji_status'
AND (e.emoji_status_payload #>> '{collectible,pattern_document_id}')::bigint = r.old_document_id;
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
SELECT user_id, 0 FROM telesrv_repaired_collectible_wearers
ON CONFLICT (user_id) DO NOTHING;
CREATE TEMP TABLE telesrv_collectible_pattern_correction_events (
user_id bigint PRIMARY KEY,
pts integer NOT NULL
) ON COMMIT DROP;
WITH bumped AS (
UPDATE public.user_update_watermarks w
SET contiguous_pts = contiguous_pts + 1,
updated_at = now()
FROM telesrv_repaired_collectible_wearers wearer
WHERE w.user_id = wearer.user_id
RETURNING w.user_id, w.contiguous_pts
)
INSERT INTO telesrv_collectible_pattern_correction_events (user_id, pts)
SELECT user_id, contiguous_pts FROM bumped;
INSERT INTO public.user_update_events (
user_id, pts, pts_count, date, event_type,
peer_type, peer_id, emoji_status_payload
)
SELECT
c.user_id,
c.pts,
1,
EXTRACT(EPOCH FROM clock_timestamp())::integer,
'user_emoji_status',
'user',
c.user_id,
jsonb_strip_nulls(jsonb_build_object(
'document_id', u.emoji_status_document_id,
'until', CASE WHEN u.emoji_status_until > 0 THEN u.emoji_status_until ELSE NULL END,
'collectible', u.emoji_status_collectible
))
FROM telesrv_collectible_pattern_correction_events c
JOIN public.users u ON u.id = c.user_id;
INSERT INTO public.dispatch_outbox (
target_user_id, pts, event_type, exclude_auth_key_id, exclude_session_id
)
SELECT user_id, pts, 'user_emoji_status', 0, 0
FROM telesrv_collectible_pattern_correction_events;

View file

@ -0,0 +1,98 @@
CREATE TEMP TABLE telesrv_pattern_preview_repair_rollback ON COMMIT DROP AS
SELECT old_document_id, new_document_id
FROM public.star_gift_pattern_preview_document_repairs;
CREATE TEMP TABLE telesrv_pattern_preview_rollback_wearers ON COMMIT DROP AS
SELECT u.id AS user_id, r.old_document_id, r.new_document_id
FROM public.users u
JOIN telesrv_pattern_preview_repair_rollback r
ON (u.emoji_status_collectible->>'pattern_document_id')::bigint = r.new_document_id
WHERE u.emoji_status_collectible_id IS NOT NULL;
UPDATE public.users u
SET emoji_status_collectible = jsonb_set(
u.emoji_status_collectible,
'{pattern_document_id}',
to_jsonb(w.old_document_id),
false
),
updated_at = now()
FROM telesrv_pattern_preview_rollback_wearers w
WHERE u.id = w.user_id;
UPDATE public.user_update_events e
SET emoji_status_payload = jsonb_set(
e.emoji_status_payload,
'{collectible,pattern_document_id}',
to_jsonb(r.old_document_id),
false
)
FROM telesrv_pattern_preview_repair_rollback r
WHERE e.event_type = 'user_emoji_status'
AND (e.emoji_status_payload #>> '{collectible,pattern_document_id}')::bigint = r.new_document_id;
ALTER TABLE public.star_gift_collectible_patterns
DISABLE TRIGGER star_gift_collectible_pattern_guard;
UPDATE public.star_gift_collectible_patterns p
SET document_id = r.old_document_id
FROM telesrv_pattern_preview_repair_rollback r
WHERE p.document_id = r.new_document_id;
ALTER TABLE public.star_gift_collectible_patterns
ENABLE TRIGGER star_gift_collectible_pattern_guard;
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
SELECT user_id, 0 FROM telesrv_pattern_preview_rollback_wearers
ON CONFLICT (user_id) DO NOTHING;
CREATE TEMP TABLE telesrv_pattern_preview_rollback_events (
user_id bigint PRIMARY KEY,
pts integer NOT NULL
) ON COMMIT DROP;
WITH bumped AS (
UPDATE public.user_update_watermarks w
SET contiguous_pts = contiguous_pts + 1,
updated_at = now()
FROM telesrv_pattern_preview_rollback_wearers wearer
WHERE w.user_id = wearer.user_id
RETURNING w.user_id, w.contiguous_pts
)
INSERT INTO telesrv_pattern_preview_rollback_events (user_id, pts)
SELECT user_id, contiguous_pts FROM bumped;
INSERT INTO public.user_update_events (
user_id, pts, pts_count, date, event_type,
peer_type, peer_id, emoji_status_payload
)
SELECT
c.user_id,
c.pts,
1,
EXTRACT(EPOCH FROM clock_timestamp())::integer,
'user_emoji_status',
'user',
c.user_id,
jsonb_strip_nulls(jsonb_build_object(
'document_id', u.emoji_status_document_id,
'until', CASE WHEN u.emoji_status_until > 0 THEN u.emoji_status_until ELSE NULL END,
'collectible', u.emoji_status_collectible
))
FROM telesrv_pattern_preview_rollback_events c
JOIN public.users u ON u.id = c.user_id;
INSERT INTO public.dispatch_outbox (
target_user_id, pts, event_type, exclude_auth_key_id, exclude_session_id
)
SELECT user_id, pts, 'user_emoji_status', 0, 0
FROM telesrv_pattern_preview_rollback_events;
DELETE FROM public.file_blobs f
USING telesrv_pattern_preview_repair_rollback r
WHERE f.location_key = 'doc:' || r.new_document_id::text
OR f.location_key LIKE 'doc:' || r.new_document_id::text || ':%';
DROP TABLE public.star_gift_pattern_preview_document_repairs;
DELETE FROM public.documents d
USING telesrv_pattern_preview_repair_rollback r
WHERE d.id = r.new_document_id;

View file

@ -0,0 +1,168 @@
-- DrKLO's profile-header path creates collectible patterns with
-- CACHE_TYPE_ALERT_PREVIEW_STATIC. For application/x-tgsticker that client
-- only loads the main TGS when Document.thumbs is non-empty; an empty list is
-- routed to a null thumbnail location and the pattern remains a flat backdrop.
--
-- Document metadata is cached by id, so adding a thumb to an already published
-- id would not repair existing Android installations. Clone every affected
-- published pattern under a new immutable identity, add an inline PhotoPathSize
-- placeholder, alias the unchanged main blob, and converge active/durable emoji
-- status snapshots to the new id.
CREATE TABLE public.star_gift_pattern_preview_document_repairs (
old_document_id bigint PRIMARY KEY,
new_document_id bigint UNIQUE NOT NULL,
repaired_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT star_gift_pattern_preview_document_repairs_ids_check CHECK (
old_document_id > 0 AND new_document_id > 0 AND old_document_id <> new_document_id
)
);
INSERT INTO public.star_gift_pattern_preview_document_repairs (old_document_id, new_document_id)
SELECT DISTINCT
p.document_id,
((('x' || substr(md5(p.document_id::text || ':collectible-pattern-android-preview:v2'), 1, 16))
::bit(64)::bigint) & 9223372036854775807)
FROM public.star_gift_collectible_patterns p
JOIN public.documents d ON d.id = p.document_id
WHERE jsonb_array_length(d.thumbs) = 0;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM public.star_gift_pattern_preview_document_repairs r
JOIN public.documents d ON d.id = r.new_document_id
) OR EXISTS (
SELECT 1
FROM public.star_gift_pattern_preview_document_repairs r
JOIN public.file_blobs f
ON f.location_key = 'doc:' || r.new_document_id::text
OR f.location_key LIKE 'doc:' || r.new_document_id::text || ':%'
) THEN
RAISE EXCEPTION 'collectible pattern preview document repair id collision';
END IF;
END
$$;
INSERT INTO public.documents (
id, access_hash, file_reference, date, mime_type, size, dc_id,
attributes, thumbs, created_at
)
SELECT
r.new_document_id,
d.access_hash,
d.file_reference,
d.date,
d.mime_type,
d.size,
d.dc_id,
d.attributes,
jsonb_build_array(jsonb_build_object(
'kind', 'path',
'type', 'j',
'bytes', 'GQalBdxhTX54SARIBGNsfE4Imk4HooCjlLqhhYOHSIxMjEybVa1VkICfhqqRqquGigRYjgFNkXmHA0cGhwM='
)),
now()
FROM public.star_gift_pattern_preview_document_repairs r
JOIN public.documents d ON d.id = r.old_document_id;
INSERT INTO public.file_blobs (
location_key, backend, object_key, size, sha256, mime_type, created_at
)
SELECT
'doc:' || r.new_document_id::text ||
substr(f.location_key, length('doc:' || r.old_document_id::text) + 1),
f.backend,
f.object_key,
f.size,
f.sha256,
f.mime_type,
now()
FROM public.star_gift_pattern_preview_document_repairs r
JOIN public.file_blobs f
ON f.location_key = 'doc:' || r.old_document_id::text
OR f.location_key LIKE 'doc:' || r.old_document_id::text || ':%';
ALTER TABLE public.star_gift_collectible_patterns
DISABLE TRIGGER star_gift_collectible_pattern_guard;
UPDATE public.star_gift_collectible_patterns p
SET document_id = r.new_document_id
FROM public.star_gift_pattern_preview_document_repairs r
WHERE p.document_id = r.old_document_id;
ALTER TABLE public.star_gift_collectible_patterns
ENABLE TRIGGER star_gift_collectible_pattern_guard;
CREATE TEMP TABLE telesrv_pattern_preview_repaired_wearers ON COMMIT DROP AS
SELECT u.id AS user_id, r.old_document_id, r.new_document_id
FROM public.users u
JOIN public.star_gift_pattern_preview_document_repairs r
ON (u.emoji_status_collectible->>'pattern_document_id')::bigint = r.old_document_id
WHERE u.emoji_status_collectible_id IS NOT NULL;
UPDATE public.users u
SET emoji_status_collectible = jsonb_set(
u.emoji_status_collectible,
'{pattern_document_id}',
to_jsonb(w.new_document_id),
false
),
updated_at = now()
FROM telesrv_pattern_preview_repaired_wearers w
WHERE u.id = w.user_id;
UPDATE public.user_update_events e
SET emoji_status_payload = jsonb_set(
e.emoji_status_payload,
'{collectible,pattern_document_id}',
to_jsonb(r.new_document_id),
false
)
FROM public.star_gift_pattern_preview_document_repairs r
WHERE e.event_type = 'user_emoji_status'
AND (e.emoji_status_payload #>> '{collectible,pattern_document_id}')::bigint = r.old_document_id;
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
SELECT user_id, 0 FROM telesrv_pattern_preview_repaired_wearers
ON CONFLICT (user_id) DO NOTHING;
CREATE TEMP TABLE telesrv_pattern_preview_correction_events (
user_id bigint PRIMARY KEY,
pts integer NOT NULL
) ON COMMIT DROP;
WITH bumped AS (
UPDATE public.user_update_watermarks w
SET contiguous_pts = contiguous_pts + 1,
updated_at = now()
FROM telesrv_pattern_preview_repaired_wearers wearer
WHERE w.user_id = wearer.user_id
RETURNING w.user_id, w.contiguous_pts
)
INSERT INTO telesrv_pattern_preview_correction_events (user_id, pts)
SELECT user_id, contiguous_pts FROM bumped;
INSERT INTO public.user_update_events (
user_id, pts, pts_count, date, event_type,
peer_type, peer_id, emoji_status_payload
)
SELECT
c.user_id,
c.pts,
1,
EXTRACT(EPOCH FROM clock_timestamp())::integer,
'user_emoji_status',
'user',
c.user_id,
jsonb_strip_nulls(jsonb_build_object(
'document_id', u.emoji_status_document_id,
'until', CASE WHEN u.emoji_status_until > 0 THEN u.emoji_status_until ELSE NULL END,
'collectible', u.emoji_status_collectible
))
FROM telesrv_pattern_preview_correction_events c
JOIN public.users u ON u.id = c.user_id;
INSERT INTO public.dispatch_outbox (
target_user_id, pts, event_type, exclude_auth_key_id, exclude_session_id
)
SELECT user_id, pts, 'user_emoji_status', 0, 0
FROM telesrv_pattern_preview_correction_events;

View file

@ -0,0 +1,4 @@
ALTER TABLE public.webview_requested_buttons
DROP COLUMN IF EXISTS photo_requested,
DROP COLUMN IF EXISTS username_requested,
DROP COLUMN IF EXISTS name_requested;

View file

@ -0,0 +1,4 @@
ALTER TABLE public.webview_requested_buttons
ADD COLUMN name_requested boolean NOT NULL DEFAULT false,
ADD COLUMN username_requested boolean NOT NULL DEFAULT false,
ADD COLUMN photo_requested boolean NOT NULL DEFAULT false;

View file

@ -0,0 +1,13 @@
DELETE FROM public.bot_api_updates
WHERE ephemeral_payload IS NOT NULL;
DROP INDEX IF EXISTS public.bot_api_updates_ephemeral_version_unique;
DROP INDEX IF EXISTS public.bot_api_updates_message_source_unique;
ALTER TABLE public.bot_api_updates
DROP CONSTRAINT bot_api_updates_ephemeral_shape_check,
DROP COLUMN ephemeral_payload;
CREATE UNIQUE INDEX bot_api_updates_message_source_unique
ON public.bot_api_updates (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts)
WHERE update_kind IN ('message', 'edited_message');

View file

@ -0,0 +1,61 @@
ALTER TABLE public.bot_api_updates
ADD COLUMN ephemeral_payload jsonb;
ALTER TABLE public.bot_api_updates
ADD CONSTRAINT bot_api_updates_ephemeral_shape_check CHECK (
ephemeral_payload IS NULL
OR (
peer_type = 'channel'
AND peer_id > 0
AND message_id > 0
AND source_pts = 0
AND jsonb_typeof(ephemeral_payload) = 'object'
AND jsonb_typeof(ephemeral_payload -> 'Message') = 'object'
AND (ephemeral_payload #>> '{Message,ID}') IS NOT NULL
AND (ephemeral_payload #>> '{Message,Peer,Type}') IS NOT NULL
AND (ephemeral_payload #>> '{Message,Peer,ID}') IS NOT NULL
AND (ephemeral_payload #>> '{Message,SenderUserID}') IS NOT NULL
AND (ephemeral_payload #>> '{Message,ReceiverUserID}') IS NOT NULL
AND (ephemeral_payload #>> '{Message,Version}') IS NOT NULL
AND NOT ((ephemeral_payload -> 'Message') ?| ARRAY[
'RandomID', 'OriginDevice', 'PayloadHash', 'CreatedAt', 'Deleted'
])
AND (
NOT (ephemeral_payload ? 'ReplyTo')
OR (
jsonb_typeof(ephemeral_payload -> 'ReplyTo') = 'object'
AND NOT ((ephemeral_payload -> 'ReplyTo') ?| ARRAY[
'RandomID', 'OriginDevice', 'PayloadHash', 'CreatedAt', 'Deleted'
])
)
)
AND (ephemeral_payload #>> '{Message,ID}')::integer = message_id
AND (ephemeral_payload #>> '{Message,Peer,Type}') = peer_type
AND (ephemeral_payload #>> '{Message,Peer,ID}')::bigint = peer_id
AND (
(update_kind = 'callback_query'
AND (ephemeral_payload #>> '{Message,SenderUserID}')::bigint = bot_user_id)
OR
(update_kind IN ('message', 'edited_message')
AND (ephemeral_payload #>> '{Message,ReceiverUserID}')::bigint = bot_user_id)
)
AND (ephemeral_payload #>> '{Message,Version}')::bigint > 0
)
);
DROP INDEX public.bot_api_updates_message_source_unique;
CREATE UNIQUE INDEX bot_api_updates_message_source_unique
ON public.bot_api_updates (bot_user_id, update_kind, peer_type, peer_id, message_id, source_pts)
WHERE update_kind IN ('message', 'edited_message') AND ephemeral_payload IS NULL;
CREATE UNIQUE INDEX bot_api_updates_ephemeral_version_unique
ON public.bot_api_updates (
bot_user_id,
update_kind,
peer_type,
peer_id,
message_id,
((ephemeral_payload #>> '{Message,Version}')::bigint)
)
WHERE update_kind IN ('message', 'edited_message') AND ephemeral_payload IS NOT NULL;

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.ephemeral_abuse_reports;

View file

@ -0,0 +1,23 @@
CREATE TABLE public.ephemeral_abuse_reports (
id bigserial PRIMARY KEY,
reporter_user_id bigint NOT NULL CHECK (reporter_user_id > 0),
channel_id bigint NOT NULL CHECK (channel_id > 0),
ephemeral_message_id integer NOT NULL CHECK (ephemeral_message_id > 0),
sender_user_id bigint NOT NULL CHECK (sender_user_id > 0),
receiver_user_id bigint NOT NULL CHECK (receiver_user_id = reporter_user_id),
report_option text NOT NULL CHECK (length(report_option) BETWEEN 1 AND 64),
report_comment text NOT NULL DEFAULT '' CHECK (length(report_comment) <= 4096),
comment_hash bytea NOT NULL CHECK (octet_length(comment_hash) = 32),
payload_hash bytea NOT NULL CHECK (octet_length(payload_hash) = 32),
evidence jsonb NOT NULL CHECK (jsonb_typeof(evidence) = 'object'),
created_at timestamptz NOT NULL,
CONSTRAINT ephemeral_abuse_reports_idempotency UNIQUE (
reporter_user_id, channel_id, ephemeral_message_id, report_option, comment_hash
)
);
CREATE INDEX ephemeral_abuse_reports_created_at_idx
ON public.ephemeral_abuse_reports (created_at DESC, id DESC);
CREATE INDEX ephemeral_abuse_reports_sender_created_idx
ON public.ephemeral_abuse_reports (sender_user_id, created_at DESC, id DESC);

View file

@ -0,0 +1,20 @@
ALTER TABLE public.user_update_events
DROP CONSTRAINT IF EXISTS user_update_events_peer_type_check;
ALTER TABLE public.user_update_events
ADD CONSTRAINT user_update_events_peer_type_check
CHECK (peer_type IS NULL OR peer_type IN ('user','channel'));
DELETE FROM public.notify_settings WHERE scope_kind='peer' AND peer_type='community';
DROP TRIGGER IF EXISTS users_linked_community_read_model_changed ON public.users;
DROP FUNCTION IF EXISTS public.telesrv_notify_user_linked_community_read_model();
DROP INDEX IF EXISTS public.channels_linked_community_idx;
DROP INDEX IF EXISTS public.users_linked_community_idx;
ALTER TABLE public.channels DROP COLUMN IF EXISTS linked_community_id;
ALTER TABLE public.users DROP COLUMN IF EXISTS linked_community_id;
DROP TABLE IF EXISTS public.community_user_states;
DROP TABLE IF EXISTS public.community_peer_link_requests;
DROP TABLE IF EXISTS public.community_peer_links;
DROP TABLE IF EXISTS public.community_members;
DROP TABLE IF EXISTS public.communities;

View file

@ -0,0 +1,130 @@
-- Layer 228 Communities are aggregation containers. Linked peer dialogs keep
-- their own messages/read state/pts; these tables only persist the container,
-- links, moderation, pending requests and per-user dialog presentation.
ALTER TABLE public.user_update_events
DROP CONSTRAINT IF EXISTS user_update_events_peer_type_check;
ALTER TABLE public.user_update_events
ADD CONSTRAINT user_update_events_peer_type_check
CHECK (peer_type IS NULL OR peer_type IN ('user','channel','community'));
CREATE TABLE public.communities (
id bigint PRIMARY KEY,
access_hash bigint NOT NULL,
creator_user_id bigint NOT NULL REFERENCES public.users(id),
title text NOT NULL,
about text DEFAULT ''::text NOT NULL,
default_banned_rights jsonb DEFAULT '{}'::jsonb NOT NULL,
photo_id bigint DEFAULT 0 NOT NULL,
photo_dc_id integer DEFAULT 0 NOT NULL,
photo_stripped bytea DEFAULT '\x'::bytea NOT NULL,
date integer NOT NULL,
deleted boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT communities_positive_check CHECK (id > 0 AND creator_user_id > 0),
CONSTRAINT communities_title_check CHECK (length(btrim(title)) > 0)
);
CREATE UNIQUE INDEX communities_access_hash_idx ON public.communities(access_hash);
CREATE INDEX communities_creator_idx ON public.communities(creator_user_id, id) WHERE NOT deleted;
CREATE TABLE public.community_members (
community_id bigint NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE,
user_id bigint NOT NULL REFERENCES public.users(id),
role text DEFAULT 'member'::text NOT NULL,
status text DEFAULT 'active'::text NOT NULL,
admin_rights jsonb DEFAULT '{}'::jsonb NOT NULL,
rank text DEFAULT ''::text NOT NULL,
date integer NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY (community_id, user_id),
CONSTRAINT community_members_role_check CHECK (role IN ('creator','admin','member')),
CONSTRAINT community_members_status_check CHECK (status IN ('active','kicked'))
);
CREATE UNIQUE INDEX community_one_creator_idx
ON public.community_members(community_id) WHERE role = 'creator' AND status = 'active';
CREATE INDEX community_members_user_idx ON public.community_members(user_id, community_id) WHERE status = 'active';
CREATE INDEX community_members_kicked_idx ON public.community_members(community_id, user_id) WHERE status = 'kicked';
CREATE TABLE public.community_peer_links (
community_id bigint NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE,
peer_type text NOT NULL,
peer_id bigint NOT NULL,
visibility text NOT NULL,
created_by bigint NOT NULL REFERENCES public.users(id),
date integer NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY (community_id, peer_type, peer_id),
CONSTRAINT community_peer_links_type_check CHECK (peer_type IN ('channel','user')),
CONSTRAINT community_peer_links_visibility_check CHECK (visibility IN ('visible','hidden')),
CONSTRAINT community_peer_links_positive_check CHECK (peer_id > 0 AND created_by > 0)
);
-- A group/channel/bot may belong to only one Community.
CREATE UNIQUE INDEX community_peer_links_unique_peer_idx ON public.community_peer_links(peer_type, peer_id);
CREATE INDEX community_peer_links_community_idx ON public.community_peer_links(community_id, date, peer_type, peer_id);
CREATE TABLE public.community_peer_link_requests (
community_id bigint NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE,
peer_type text NOT NULL,
peer_id bigint NOT NULL,
requested_by bigint NOT NULL REFERENCES public.users(id),
visibility text NOT NULL,
date integer NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY (community_id, peer_type, peer_id),
CONSTRAINT community_peer_requests_type_check CHECK (peer_type IN ('channel','user')),
CONSTRAINT community_peer_requests_visibility_check CHECK (visibility IN ('visible','hidden')),
CONSTRAINT community_peer_requests_positive_check CHECK (peer_id > 0 AND requested_by > 0)
);
CREATE INDEX community_peer_requests_page_idx
ON public.community_peer_link_requests(community_id, date DESC, peer_type, peer_id);
CREATE TABLE public.community_user_states (
community_id bigint NOT NULL REFERENCES public.communities(id) ON DELETE CASCADE,
user_id bigint NOT NULL REFERENCES public.users(id),
collapsed boolean DEFAULT false NOT NULL,
pinned boolean DEFAULT false NOT NULL,
pinned_order integer DEFAULT 0 NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY (community_id, user_id),
CONSTRAINT community_user_states_order_check CHECK (pinned_order >= 0)
);
CREATE INDEX community_user_states_pinned_idx
ON public.community_user_states(user_id, pinned_order, community_id) WHERE pinned;
ALTER TABLE public.users
ADD COLUMN linked_community_id bigint DEFAULT 0 NOT NULL;
ALTER TABLE public.channels
ADD COLUMN linked_community_id bigint DEFAULT 0 NOT NULL;
CREATE INDEX users_linked_community_idx ON public.users(linked_community_id) WHERE linked_community_id <> 0;
CREATE INDEX channels_linked_community_idx ON public.channels(linked_community_id) WHERE linked_community_id <> 0;
-- linked_community_id is part of the Layer 228 user projection. The legacy
-- user-base trigger intentionally lists projected columns and therefore does
-- not notice this newly-added column; emit the same read-model bumps here so
-- Redis/base-user and contact projections cannot retain a stale bot link.
CREATE FUNCTION public.telesrv_notify_user_linked_community_read_model() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
PERFORM telesrv_bump_read_model_version('user_base', NEW.id, 'user', NEW.id);
PERFORM telesrv_bump_read_model_version('contact_account', c.user_id, 'user', c.user_id)
FROM contacts c
WHERE c.contact_user_id = NEW.id;
PERFORM telesrv_bump_private_dialog_light_for_user(NEW.id);
RETURN NULL;
END;
$$;
CREATE TRIGGER users_linked_community_read_model_changed
AFTER UPDATE OF linked_community_id ON public.users
FOR EACH ROW
WHEN (OLD.linked_community_id IS DISTINCT FROM NEW.linked_community_id)
EXECUTE FUNCTION public.telesrv_notify_user_linked_community_read_model();
-- Community notification settings reuse the existing peer-scoped table with a
-- distinct peer_type. No new scope_kind is required.