feat: sync official star gift lifecycle tooling

Sync telesrv 4c0e2d9 (feat: complete official star gift lifecycle and admin tooling).

Public adjustments: skipped private docs/deploy-nginx/README source changes, kept iamxvbaba/td public dependency, replaced local/private sample IP and orange seed label.
This commit is contained in:
A 2026-07-19 03:11:35 +08:00
parent f2c2fd0236
commit 14bf7d1e20
92 changed files with 14768 additions and 727 deletions

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
);