fix: sync StarGift lifecycle hardening

This commit is contained in:
A 2026-07-21 15:47:55 +08:00
parent bfdfe825f6
commit d69a34a4a8
30 changed files with 1883 additions and 132 deletions

View file

@ -0,0 +1,6 @@
-- Durable edit events and repaired user gift projections are intentionally not
-- rewound. Dropping the new lookup/index constraints is sufficient rollback.
ALTER TABLE peer_star_gifts
DROP CONSTRAINT IF EXISTS peer_star_gifts_hidden_unpinned_check;
DROP TABLE IF EXISTS star_gift_user_message_refs;

View file

@ -0,0 +1,127 @@
-- Official clients may continue lifecycle actions from a freshly emitted
-- messageActionStarGiftUnique. Keep those user-local message ids as explicit
-- durable references to the same saved gift aggregate.
CREATE TABLE star_gift_user_message_refs (
owner_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
msg_id integer NOT NULL,
saved_gift_id bigint NOT NULL REFERENCES peer_star_gifts(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (owner_user_id, msg_id),
CONSTRAINT star_gift_user_message_refs_msg_check CHECK (owner_user_id > 0 AND msg_id > 0)
);
CREATE INDEX star_gift_user_message_refs_saved_idx
ON star_gift_user_message_refs(saved_gift_id, owner_user_id, msg_id);
INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id)
SELECT box.owner_user_id, box.box_id, gift.id
FROM message_boxes box
JOIN unique_star_gifts unique_gift
ON (box.media #>> '{service_action,star_gift_unique,gift,ID}') ~ '^[0-9]+$'
AND unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint
JOIN peer_star_gifts gift
ON gift.id = unique_gift.source_saved_gift_id
AND gift.owner_peer_type = 'user'
AND gift.owner_peer_id = box.owner_user_id
WHERE NOT box.deleted
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND box.box_id <> gift.msg_id;
-- Hidden gifts cannot remain pinned. Compacting the whole owner vector here
-- also repairs historical gaps before the invariant is constrained.
ALTER TABLE peer_star_gifts
ADD CONSTRAINT peer_star_gifts_hidden_unpinned_check
CHECK (pinned_order<=6 AND (NOT unsaved OR pinned_order=0)) NOT VALID;
CREATE TEMP TABLE star_gift_pin_repairs ON COMMIT DROP AS
SELECT id,new_order
FROM (
SELECT id,
row_number() OVER (PARTITION BY owner_peer_type,owner_peer_id
ORDER BY pinned_order,id)::integer AS new_order
FROM peer_star_gifts
WHERE lifecycle_status='active' AND NOT unsaved AND pinned_order>0
) ranked
WHERE new_order<=6;
UPDATE peer_star_gifts SET pinned_order=0 WHERE pinned_order<>0;
UPDATE peer_star_gifts gift
SET pinned_order=repair.new_order
FROM star_gift_pin_repairs repair
WHERE gift.id=repair.id;
-- peer and saved_id share one TL flag and are channel-only. Earlier user gift
-- projections set peer=user (and sometimes a user box id in saved_id), which
-- made TDesktop select zero/stale ids instead of the emitted service message.
CREATE TEMP TABLE star_gift_user_unique_media_repairs (
owner_user_id bigint NOT NULL,
box_id integer NOT NULL,
peer_type text NOT NULL,
peer_id bigint NOT NULL,
repaired_media jsonb NOT NULL,
PRIMARY KEY(owner_user_id,box_id)
) ON COMMIT DROP;
INSERT INTO star_gift_user_unique_media_repairs(owner_user_id,box_id,peer_type,peer_id,repaired_media)
SELECT box.owner_user_id,
box.box_id,
box.peer_type,
box.peer_id,
jsonb_set(
box.media #- '{service_action,star_gift_unique,saved_id}',
'{service_action,star_gift_unique,peer}',
'{"ID":0,"Type":""}'::jsonb,
true
)
FROM message_boxes box
WHERE NOT box.deleted
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND box.media #>> '{service_action,star_gift_unique,peer,Type}' = 'user';
DO $$
DECLARE
repair record;
next_pts integer;
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
BEGIN
FOR repair IN
SELECT owner_user_id,box_id,peer_type,peer_id,repaired_media
FROM star_gift_user_unique_media_repairs
ORDER BY owner_user_id,box_id
LOOP
INSERT INTO user_update_watermarks(user_id,contiguous_pts)
VALUES(repair.owner_user_id,0)
ON CONFLICT(user_id) DO NOTHING;
UPDATE user_update_watermarks
SET contiguous_pts=contiguous_pts+1,updated_at=now()
WHERE user_id=repair.owner_user_id
RETURNING contiguous_pts INTO next_pts;
UPDATE message_boxes
SET media=repair.repaired_media,pts=next_pts
WHERE owner_user_id=repair.owner_user_id AND box_id=repair.box_id AND NOT deleted;
INSERT INTO user_update_events(
user_id,pts,pts_count,date,event_type,message_box_id,peer_type,peer_id
) VALUES(
repair.owner_user_id,next_pts,1,event_date,'edit_message',repair.box_id,repair.peer_type,repair.peer_id
);
INSERT INTO dispatch_outbox(
target_user_id,pts,event_type,exclude_auth_key_id,exclude_session_id
) VALUES(repair.owner_user_id,next_pts,'edit_message',0,0);
END LOOP;
END
$$;
UPDATE private_messages
SET media=jsonb_set(
media #- '{service_action,star_gift_unique,saved_id}',
'{service_action,star_gift_unique,peer}',
'{"ID":0,"Type":""}'::jsonb,
true
)
WHERE media #>> '{service_action,kind}' = 'star_gift_unique'
AND media #>> '{service_action,star_gift_unique,peer,Type}' = 'user';

View file

@ -0,0 +1 @@
-- Validation changes no data and the constraint belongs to migration 0126.

View file

@ -0,0 +1,5 @@
-- 0126 repaired historical rows and installed the constraint as NOT VALID so
-- it could coexist with the deferrable unique-gift owner trigger in one
-- migration transaction. Validate after that transaction has committed.
ALTER TABLE peer_star_gifts
VALIDATE CONSTRAINT peer_star_gifts_hidden_unpinned_check;

View file

@ -0,0 +1,38 @@
-- Aggregate/message repairs and emitted edit events are authoritative business
-- history and are intentionally not reversed. Restore only the pre-0128
-- deferred owner guard shape.
CREATE OR REPLACE 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;
$$;

View file

@ -0,0 +1,498 @@
-- Official Android clients use a positive can_craft_at both as the Craft
-- capability marker and as the readiness boundary. Earlier zero-delay
-- upgrades persisted 0 while retaining a positive craft chance, so TDesktop
-- could Craft the gift but Android hid the entry entirely.
--
-- Block concurrent lifecycle/message writers while aggregate facts, message
-- snapshots and durable edit edges are repaired in this migration transaction.
LOCK TABLE public.peer_star_gifts, public.unique_star_gifts,
public.message_boxes, public.private_messages IN SHARE ROW EXCLUSIVE MODE;
DO $$
BEGIN
-- Craft capability remains an intrinsic collectible fact while ownership
-- moves between users and channels. Terminal/external states cannot Craft.
UPDATE public.unique_star_gifts unique_gift
SET craft_chance_permille = 0,
updated_at = now()
FROM public.peer_star_gifts saved_gift
WHERE saved_gift.unique_gift_id = unique_gift.id
AND unique_gift.craft_chance_permille > 0
AND (
saved_gift.lifecycle_status <> 'active'
OR unique_gift.owner_address <> ''
OR unique_gift.burned
OR unique_gift.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 saved_gift.can_craft_at <> 0;
IF EXISTS (
SELECT 1
FROM public.unique_star_gifts unique_gift
JOIN public.peer_star_gifts saved_gift
ON saved_gift.unique_gift_id = unique_gift.id
WHERE unique_gift.craft_chance_permille > 0
AND (
saved_gift.owner_peer_type NOT IN ('user', 'channel')
OR saved_gift.lifecycle_status <> 'active'
OR unique_gift.owner_address <> ''
OR unique_gift.burned
OR unique_gift.crafted
OR NOT EXISTS (
SELECT 1
FROM public.star_gift_collectible_models model
WHERE model.collectible_revision_id = unique_gift.collectible_revision_id
AND model.crafted
)
)
) THEN
RAISE EXCEPTION 'positive star gift craft chance has no valid owned aggregate';
END IF;
-- created_at is the stable persisted proxy for the original upgrade
-- transaction date on legacy rows. New writes use the exact request date.
UPDATE public.peer_star_gifts saved_gift
SET can_craft_at = GREATEST(
1,
LEAST(2147483647, FLOOR(EXTRACT(EPOCH FROM unique_gift.created_at))::bigint)::integer
)
FROM public.unique_star_gifts unique_gift
WHERE unique_gift.id = saved_gift.unique_gift_id
AND unique_gift.craft_chance_permille > 0
AND saved_gift.can_craft_at = 0;
IF EXISTS (
SELECT 1
FROM public.peer_star_gifts saved_gift
JOIN public.unique_star_gifts unique_gift
ON unique_gift.id = saved_gift.unique_gift_id
WHERE (unique_gift.craft_chance_permille > 0)
IS DISTINCT FROM (saved_gift.can_craft_at > 0)
) THEN
RAISE EXCEPTION 'star gift craft chance/readiness repair did not converge';
END IF;
END
$$;
CREATE TEMP TABLE star_gift_craft_message_repairs (
owner_user_id bigint NOT NULL,
box_id integer NOT NULL,
unique_gift_id bigint NOT NULL,
desired_craft_chance integer NOT NULL,
desired_can_craft_at integer NOT NULL,
PRIMARY KEY (owner_user_id, box_id)
) ON COMMIT DROP;
-- Adding capability is owner-scoped: repair only the current owner's
-- authoritative unique action (upgrade_msg_id) and the other visible box of
-- that same logical private message. Never add Craft back to an old owner's
-- historical transfer/resale action.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM public.peer_star_gifts saved_gift
JOIN public.unique_star_gifts unique_gift
ON unique_gift.id = saved_gift.unique_gift_id
WHERE saved_gift.owner_peer_type = 'user'
AND saved_gift.lifecycle_status = 'active'
AND saved_gift.can_craft_at > 0
AND NOT EXISTS (
SELECT 1
FROM public.message_boxes owner_box
WHERE owner_box.owner_user_id = saved_gift.owner_peer_id
AND owner_box.box_id = saved_gift.upgrade_msg_id
AND NOT owner_box.deleted
AND owner_box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND owner_box.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text
)
) THEN
RAISE EXCEPTION 'craftable star gift is missing its current owner action';
END IF;
IF EXISTS (
SELECT 1
FROM public.message_boxes box
WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND box.media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
AND (
jsonb_typeof(box.media #> '{service_action,star_gift_unique,can_craft_at}') <> 'number'
OR COALESCE(box.media #>> '{service_action,star_gift_unique,can_craft_at}', '') !~ '^[0-9]+$'
OR (box.media #>> '{service_action,star_gift_unique,can_craft_at}')::numeric > 2147483647
)
) THEN
RAISE EXCEPTION 'star gift message has malformed can_craft_at';
END IF;
IF EXISTS (
SELECT 1
FROM public.message_boxes box
WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL
AND (
jsonb_typeof(box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}') <> 'number'
OR COALESCE(box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}', '') !~ '^[0-9]+$'
OR (box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}')::numeric > 1000
)
) THEN
RAISE EXCEPTION 'star gift message has malformed craft chance';
END IF;
END
$$;
INSERT INTO star_gift_craft_message_repairs(
owner_user_id, box_id, unique_gift_id,
desired_craft_chance, desired_can_craft_at
)
SELECT visible_box.owner_user_id,
visible_box.box_id,
unique_gift.id,
unique_gift.craft_chance_permille,
saved_gift.can_craft_at
FROM public.peer_star_gifts saved_gift
JOIN public.unique_star_gifts unique_gift
ON unique_gift.id = saved_gift.unique_gift_id
JOIN public.message_boxes owner_box
ON owner_box.owner_user_id = saved_gift.owner_peer_id
AND owner_box.box_id = saved_gift.upgrade_msg_id
AND NOT owner_box.deleted
AND owner_box.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text
JOIN public.message_boxes visible_box
ON visible_box.message_sender_id = owner_box.message_sender_id
AND visible_box.private_message_id = owner_box.private_message_id
AND NOT visible_box.deleted
WHERE saved_gift.owner_peer_type = 'user'
AND saved_gift.lifecycle_status = 'active'
AND saved_gift.can_craft_at > 0
AND (
COALESCE(NULLIF(visible_box.media #>> '{service_action,star_gift_unique,can_craft_at}', '')::integer, 0)
IS DISTINCT FROM saved_gift.can_craft_at
OR COALESCE(NULLIF(visible_box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}', '')::integer, 0)
IS DISTINCT FROM unique_gift.craft_chance_permille
);
-- Only the current owner's authoritative logical message may expose Craft.
-- Terminal gifts, channel-owned gifts (until channel Craft is implemented),
-- and old-owner historical actions must have both wire markers removed.
INSERT INTO star_gift_craft_message_repairs(
owner_user_id, box_id, unique_gift_id,
desired_craft_chance, desired_can_craft_at
)
SELECT box.owner_user_id, box.box_id, unique_gift.id, 0, 0
FROM public.message_boxes box
JOIN public.unique_star_gifts unique_gift
ON (box.media #>> '{service_action,star_gift_unique,gift,ID}') ~ '^[0-9]+$'
AND unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint
JOIN public.peer_star_gifts saved_gift
ON saved_gift.unique_gift_id = unique_gift.id
WHERE NOT box.deleted
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND (
box.media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
OR box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL
)
AND NOT EXISTS (
SELECT 1
FROM public.message_boxes authority
WHERE saved_gift.owner_peer_type = 'user'
AND saved_gift.lifecycle_status = 'active'
AND saved_gift.can_craft_at > 0
AND unique_gift.craft_chance_permille > 0
AND authority.owner_user_id = saved_gift.owner_peer_id
AND authority.box_id = saved_gift.upgrade_msg_id
AND NOT authority.deleted
AND authority.media #>> '{service_action,kind}' = 'star_gift_unique'
AND authority.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text
AND authority.message_sender_id = box.message_sender_id
AND authority.private_message_id = box.private_message_id
)
ON CONFLICT (owner_user_id, box_id) DO UPDATE
SET unique_gift_id = EXCLUDED.unique_gift_id,
desired_craft_chance = 0,
desired_can_craft_at = 0;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM star_gift_craft_message_repairs target
JOIN public.message_boxes box
ON box.owner_user_id = target.owner_user_id
AND box.box_id = target.box_id
WHERE box.deleted
OR box.media #>> '{service_action,kind}' <> 'star_gift_unique'
OR box.media #>> '{service_action,star_gift_unique,gift,ID}' <> target.unique_gift_id::text
) THEN
RAISE EXCEPTION 'craft readiness repair target is not the expected unique gift action';
END IF;
IF EXISTS (
SELECT 1
FROM star_gift_craft_message_repairs target
JOIN public.message_boxes box
ON box.owner_user_id = target.owner_user_id
AND box.box_id = target.box_id
WHERE NOT EXISTS (
SELECT 1
FROM public.private_messages private_message
WHERE private_message.sender_user_id = box.message_sender_id
AND private_message.id = box.private_message_id
AND private_message.media #>> '{service_action,kind}' = 'star_gift_unique'
AND private_message.media #>> '{service_action,star_gift_unique,gift,ID}' = target.unique_gift_id::text
)
) THEN
RAISE EXCEPTION 'craft readiness repair target has no matching private message';
END IF;
IF EXISTS (
SELECT 1
FROM star_gift_craft_message_repairs target
JOIN public.message_boxes box
ON box.owner_user_id = target.owner_user_id
AND box.box_id = target.box_id
GROUP BY box.message_sender_id, box.private_message_id
HAVING COUNT(DISTINCT (
target.unique_gift_id,
target.desired_craft_chance,
target.desired_can_craft_at
)) <> 1
) THEN
RAISE EXCEPTION 'craft readiness repair has conflicting logical message targets';
END IF;
END
$$;
DO $$
DECLARE
repair record;
next_pts integer;
event_date integer := LEAST(2147483647, EXTRACT(EPOCH FROM clock_timestamp())::bigint)::integer;
repaired_media jsonb;
repaired_private_media jsonb;
affected_rows bigint;
BEGIN
FOR repair IN
SELECT target.owner_user_id,
target.box_id,
target.unique_gift_id,
target.desired_craft_chance,
target.desired_can_craft_at,
box.peer_type,
box.peer_id,
box.message_sender_id,
box.private_message_id,
box.media
FROM star_gift_craft_message_repairs target
JOIN public.message_boxes box
ON box.owner_user_id = target.owner_user_id
AND box.box_id = target.box_id
AND NOT box.deleted
ORDER BY target.owner_user_id, target.box_id
FOR UPDATE OF box
LOOP
IF repair.desired_craft_chance > 0 THEN
repaired_media := jsonb_set(
jsonb_set(
repair.media,
'{service_action,star_gift_unique,gift,CraftChancePermille}',
to_jsonb(repair.desired_craft_chance),
true
),
'{service_action,star_gift_unique,can_craft_at}',
to_jsonb(repair.desired_can_craft_at),
true
);
ELSE
repaired_media := repair.media
#- '{service_action,star_gift_unique,can_craft_at}'
#- '{service_action,star_gift_unique,gift,CraftChancePermille}';
END IF;
IF repaired_media #>> '{service_action,kind}' <> 'star_gift_unique'
OR repaired_media #>> '{service_action,star_gift_unique,gift,ID}' <> repair.unique_gift_id::text
OR (
repair.desired_craft_chance > 0
AND (
repaired_media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}'
IS DISTINCT FROM repair.desired_craft_chance::text
OR repaired_media #>> '{service_action,star_gift_unique,can_craft_at}'
IS DISTINCT FROM repair.desired_can_craft_at::text
)
)
OR (
repair.desired_craft_chance = 0
AND (
repaired_media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL
OR repaired_media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
)
) THEN
RAISE EXCEPTION 'craft readiness repair cannot project message box for user %, box %',
repair.owner_user_id, repair.box_id;
END IF;
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
VALUES (repair.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 = repair.owner_user_id
RETURNING contiguous_pts INTO next_pts;
UPDATE public.message_boxes
SET media = repaired_media,
pts = next_pts
WHERE owner_user_id = repair.owner_user_id
AND box_id = repair.box_id
AND NOT deleted;
GET DIAGNOSTICS affected_rows = ROW_COUNT;
IF affected_rows <> 1 THEN
RAISE EXCEPTION 'craft readiness repair lost user %, box %', repair.owner_user_id, repair.box_id;
END IF;
SELECT media
INTO repaired_private_media
FROM public.private_messages
WHERE sender_user_id = repair.message_sender_id
AND id = repair.private_message_id
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'craft readiness repair missing private message for user %, box %',
repair.owner_user_id, repair.box_id;
END IF;
IF repaired_private_media #>> '{service_action,kind}' <> 'star_gift_unique'
OR repaired_private_media #>> '{service_action,star_gift_unique,gift,ID}' <> repair.unique_gift_id::text THEN
RAISE EXCEPTION 'craft readiness repair found mismatched private message for user %, box %',
repair.owner_user_id, repair.box_id;
END IF;
IF repair.desired_craft_chance > 0 THEN
repaired_private_media := jsonb_set(
jsonb_set(
repaired_private_media,
'{service_action,star_gift_unique,gift,CraftChancePermille}',
to_jsonb(repair.desired_craft_chance),
true
),
'{service_action,star_gift_unique,can_craft_at}',
to_jsonb(repair.desired_can_craft_at),
true
);
IF repaired_private_media #>> '{service_action,star_gift_unique,can_craft_at}'
IS DISTINCT FROM repair.desired_can_craft_at::text
OR repaired_private_media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}'
IS DISTINCT FROM repair.desired_craft_chance::text THEN
RAISE EXCEPTION 'craft readiness repair cannot project private message for user %, box %',
repair.owner_user_id, repair.box_id;
END IF;
ELSE
repaired_private_media := repaired_private_media
#- '{service_action,star_gift_unique,can_craft_at}'
#- '{service_action,star_gift_unique,gift,CraftChancePermille}';
IF repaired_private_media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
OR repaired_private_media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL THEN
RAISE EXCEPTION 'craft readiness repair cannot clear private message for user %, box %',
repair.owner_user_id, repair.box_id;
END IF;
END IF;
UPDATE public.private_messages
SET media = repaired_private_media
WHERE sender_user_id = repair.message_sender_id
AND id = repair.private_message_id;
GET DIAGNOSTICS affected_rows = ROW_COUNT;
IF affected_rows <> 1 THEN
RAISE EXCEPTION 'craft readiness repair lost private message for user %, box %',
repair.owner_user_id, repair.box_id;
END IF;
INSERT INTO public.user_update_events (
user_id, pts, pts_count, date, event_type,
message_box_id, peer_type, peer_id
) VALUES (
repair.owner_user_id, next_pts, 1, event_date, 'edit_message',
repair.box_id, repair.peer_type, repair.peer_id
);
INSERT INTO public.dispatch_outbox (
target_user_id, pts, event_type,
exclude_auth_key_id, exclude_session_id
) VALUES (
repair.owner_user_id, next_pts, 'edit_message', 0, 0
);
END LOOP;
END
$$;
-- Extend the existing deferred unique/saved aggregate guard. Upgrade, Craft
-- and export update the two tables in separate statements, so commit-time
-- validation observes the final atomic state without a read fallback.
CREATE OR REPLACE 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;
gift_crafted boolean;
gift_craft_chance integer;
gift_revision_id bigint;
saved_status text;
saved_owner_type text;
saved_owner_id bigint;
saved_can_craft_at integer;
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, crafted,
craft_chance_permille, collectible_revision_id
INTO gift_owner_type, gift_owner_id, gift_owner_address, gift_burned, gift_crafted,
gift_craft_chance, gift_revision_id
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, can_craft_at
INTO saved_status, saved_owner_type, saved_owner_id, saved_can_craft_at
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;
IF gift_craft_chance > 0 THEN
IF saved_can_craft_at <= 0
OR saved_status <> 'active'
OR saved_owner_type NOT IN ('user', 'channel')
OR gift_owner_address <> ''
OR gift_burned
OR gift_crafted THEN
RAISE EXCEPTION 'unique star gift craft capability has invalid aggregate state';
END IF;
IF NOT EXISTS (
SELECT 1
FROM public.star_gift_collectible_models model
WHERE model.collectible_revision_id = gift_revision_id
AND model.crafted
) THEN
RAISE EXCEPTION 'unique star gift craft chance has no crafted model';
END IF;
ELSIF saved_can_craft_at <> 0 THEN
RAISE EXCEPTION 'unique star gift readiness exists without craft chance';
END IF;
RETURN NULL;
END;
$$;

View file

@ -0,0 +1,4 @@
ALTER TABLE public.star_gift_craft_commands
DROP CONSTRAINT IF EXISTS star_gift_craft_output_receipt_check,
DROP COLUMN IF EXISTS output_fingerprint,
DROP COLUMN IF EXISTS output_media;

View file

@ -0,0 +1,65 @@
-- A successful Craft outcome and its self-service message are separated by a
-- process boundary. Freeze the exact output intent in the outcome receipt so
-- retries never rebuild a different message from mutable gift/profile state.
ALTER TABLE public.star_gift_craft_commands
ADD COLUMN output_media jsonb,
ADD COLUMN output_fingerprint bytea;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM public.star_gift_craft_commands command
WHERE command.success
AND 1 <> (
SELECT COUNT(*)
FROM public.private_messages message
WHERE message.sender_user_id = command.user_id
AND message.recipient_user_id = command.user_id
AND message.sender_snapshot #>> '{message,Media,service_action,kind}' = 'star_gift_unique'
AND message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,gift,ID}' = command.result_unique_gift_id::text
AND COALESCE((message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,craft}')::boolean, false)
AND octet_length(message.request_fingerprint) = 32
)
) THEN
RAISE EXCEPTION 'successful craft command is missing its exact immutable output receipt';
END IF;
END
$$;
WITH outputs AS (
SELECT command.user_id,
command.command_key,
message.sender_snapshot #> '{message,Media}' AS media,
message.request_fingerprint
FROM public.star_gift_craft_commands command
JOIN public.private_messages message
ON message.sender_user_id = command.user_id
AND message.recipient_user_id = command.user_id
AND message.sender_snapshot #>> '{message,Media,service_action,kind}' = 'star_gift_unique'
AND message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,gift,ID}' = command.result_unique_gift_id::text
AND COALESCE((message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,craft}')::boolean, false)
WHERE command.success
)
UPDATE public.star_gift_craft_commands command
SET output_media = output.media,
output_fingerprint = output.request_fingerprint
FROM outputs output
WHERE command.user_id = output.user_id
AND command.command_key = output.command_key;
ALTER TABLE public.star_gift_craft_commands
ADD CONSTRAINT star_gift_craft_output_receipt_check CHECK (
(success
AND result_unique_gift_id IS NOT NULL
AND output_media IS NOT NULL
AND output_media #>> '{service_action,kind}' = 'star_gift_unique'
AND COALESCE((output_media #>> '{service_action,star_gift_unique,craft}')::boolean, false)
AND output_media #>> '{service_action,star_gift_unique,gift,ID}' = result_unique_gift_id::text
AND octet_length(output_fingerprint) = 32)
OR
(NOT success
AND result_unique_gift_id IS NULL
AND output_media IS NULL
AND output_fingerprint IS NULL)
);