fix: sync StarGift lifecycle hardening
This commit is contained in:
parent
bfdfe825f6
commit
d69a34a4a8
30 changed files with 1883 additions and 132 deletions
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -0,0 +1 @@
|
|||
-- Validation changes no data and the constraint belongs to migration 0126.
|
||||
|
|
@ -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;
|
||||
38
deploy/migrations/0128_star_gift_craft_readiness.down.sql
Normal file
38
deploy/migrations/0128_star_gift_craft_readiness.down.sql
Normal 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;
|
||||
$$;
|
||||
498
deploy/migrations/0128_star_gift_craft_readiness.up.sql
Normal file
498
deploy/migrations/0128_star_gift_craft_readiness.up.sql
Normal 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;
|
||||
$$;
|
||||
|
|
@ -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;
|
||||
65
deploy/migrations/0129_star_gift_craft_output_receipt.up.sql
Normal file
65
deploy/migrations/0129_star_gift_craft_output_receipt.up.sql
Normal 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)
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue