fix(migrations): sync retire star gift projection backfill
This commit is contained in:
parent
c9371c6048
commit
af616e8c12
4 changed files with 78 additions and 228 deletions
|
|
@ -1,4 +1,3 @@
|
|||
-- Data-only viewer projection and durable PTS repair. Reintroducing the leaked
|
||||
-- owner hash or receiver-only capability into the sender box would be unsafe,
|
||||
-- and emitted updateEditMessage events cannot be retracted.
|
||||
-- Paired no-op for the retired 0163 version slot. Runtime write boundaries,
|
||||
-- not migration history, enforce viewer-specific Star Gift capabilities.
|
||||
SELECT 1;
|
||||
|
|
|
|||
|
|
@ -1,183 +1,8 @@
|
|||
-- inputInvoiceStarGiftPrepaidUpgrade is for prepaying someone else's gift:
|
||||
-- its peer is the gift owner. Earlier user-gift purchases copied both
|
||||
-- receiver-only can_upgrade and non-owner prepaid_upgrade_hash into both
|
||||
-- private message boxes. DrKLO therefore preferred the prepaid invoice on the
|
||||
-- owner's incoming card and substituted the private dialog peer (the sender),
|
||||
-- which correctly failed STARGIFT_INVALID.
|
||||
-- Retired on 2026-08-01 before any reported deployment completed this version.
|
||||
--
|
||||
-- Repair both viewer projections and publish a real account-scoped edit for
|
||||
-- every changed box. The saved-gift aggregate and its owner/hash remain intact;
|
||||
-- only the wire capability surface changes.
|
||||
|
||||
LOCK TABLE public.peer_star_gifts, public.message_boxes,
|
||||
public.private_messages IN SHARE ROW EXCLUSIVE MODE;
|
||||
|
||||
CREATE TEMP TABLE star_gift_prepaid_viewer_sources ON COMMIT DROP AS
|
||||
SELECT gift.id AS saved_gift_id,
|
||||
gift.owner_peer_id AS owner_user_id,
|
||||
gift.from_user_id AS sender_user_id,
|
||||
gift.msg_id AS owner_box_id,
|
||||
gift.gift_id,
|
||||
gift.prepaid_upgrade_hash,
|
||||
owner_box.message_sender_id,
|
||||
owner_box.private_message_id
|
||||
FROM public.peer_star_gifts gift
|
||||
JOIN public.message_boxes owner_box
|
||||
ON owner_box.owner_user_id = gift.owner_peer_id
|
||||
AND owner_box.box_id = gift.msg_id
|
||||
AND NOT owner_box.deleted
|
||||
WHERE gift.owner_peer_type = 'user'
|
||||
AND gift.lifecycle_status = 'active'
|
||||
AND gift.unique_gift_id IS NULL
|
||||
AND gift.prepaid_upgrade_stars = 0
|
||||
AND gift.prepaid_upgrade_hash <> '';
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.peer_star_gifts gift
|
||||
WHERE gift.owner_peer_type = 'user'
|
||||
AND gift.lifecycle_status = 'active'
|
||||
AND gift.unique_gift_id IS NULL
|
||||
AND gift.prepaid_upgrade_stars = 0
|
||||
AND gift.prepaid_upgrade_hash <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_prepaid_viewer_sources source
|
||||
WHERE source.saved_gift_id = gift.id
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'active user prepaid-upgrade entitlement is missing its owner message box';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_prepaid_viewer_sources source
|
||||
JOIN public.message_boxes box
|
||||
ON box.message_sender_id = source.message_sender_id
|
||||
AND box.private_message_id = source.private_message_id
|
||||
AND NOT box.deleted
|
||||
WHERE box.owner_user_id NOT IN (source.owner_user_id, source.sender_user_id)
|
||||
OR box.media #>> '{service_action,kind}' IS DISTINCT FROM 'star_gift'
|
||||
OR box.media #>> '{service_action,star_gift,gift_id}' IS DISTINCT FROM source.gift_id::text
|
||||
) THEN
|
||||
RAISE EXCEPTION 'ordinary star gift private projections disagree with the saved aggregate';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TEMP TABLE star_gift_prepaid_viewer_repairs (
|
||||
owner_user_id bigint NOT NULL,
|
||||
box_id integer NOT NULL,
|
||||
peer_type text NOT NULL,
|
||||
peer_id bigint NOT NULL,
|
||||
message_sender_id bigint NOT NULL,
|
||||
private_message_id bigint NOT NULL,
|
||||
repaired_media jsonb NOT NULL,
|
||||
PRIMARY KEY (owner_user_id, box_id)
|
||||
) ON COMMIT DROP;
|
||||
|
||||
INSERT INTO star_gift_prepaid_viewer_repairs(
|
||||
owner_user_id, box_id, peer_type, peer_id,
|
||||
message_sender_id, private_message_id, repaired_media
|
||||
)
|
||||
SELECT box.owner_user_id,
|
||||
box.box_id,
|
||||
box.peer_type,
|
||||
box.peer_id,
|
||||
box.message_sender_id,
|
||||
box.private_message_id,
|
||||
CASE
|
||||
WHEN box.owner_user_id = source.owner_user_id THEN
|
||||
box.media #- '{service_action,star_gift,prepaid_upgrade_hash}'
|
||||
ELSE
|
||||
box.media #- '{service_action,star_gift,can_upgrade}'
|
||||
END
|
||||
FROM star_gift_prepaid_viewer_sources source
|
||||
JOIN public.message_boxes box
|
||||
ON box.message_sender_id = source.message_sender_id
|
||||
AND box.private_message_id = source.private_message_id
|
||||
AND NOT box.deleted
|
||||
WHERE box.owner_user_id IN (source.owner_user_id, source.sender_user_id)
|
||||
AND box.media IS DISTINCT FROM CASE
|
||||
WHEN box.owner_user_id = source.owner_user_id THEN
|
||||
box.media #- '{service_action,star_gift,prepaid_upgrade_hash}'
|
||||
ELSE
|
||||
box.media #- '{service_action,star_gift,can_upgrade}'
|
||||
END;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
repair_row record;
|
||||
next_pts integer;
|
||||
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
|
||||
BEGIN
|
||||
FOR repair_row IN
|
||||
SELECT owner_user_id, box_id, peer_type, peer_id, repaired_media
|
||||
FROM star_gift_prepaid_viewer_repairs
|
||||
ORDER BY owner_user_id, box_id
|
||||
LOOP
|
||||
INSERT INTO public.user_update_watermarks(user_id, contiguous_pts)
|
||||
VALUES(repair_row.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_row.owner_user_id
|
||||
RETURNING contiguous_pts INTO next_pts;
|
||||
|
||||
UPDATE public.message_boxes
|
||||
SET media = repair_row.repaired_media,
|
||||
pts = next_pts
|
||||
WHERE owner_user_id = repair_row.owner_user_id
|
||||
AND box_id = repair_row.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 (
|
||||
repair_row.owner_user_id, next_pts, 1, event_date, 'edit_message',
|
||||
repair_row.box_id, repair_row.peer_type, repair_row.peer_id
|
||||
);
|
||||
|
||||
INSERT INTO public.dispatch_outbox(
|
||||
target_user_id, pts, event_type,
|
||||
exclude_auth_key_id, exclude_session_id
|
||||
) VALUES(repair_row.owner_user_id, next_pts, 'edit_message', 0, 0);
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- A shared private-message envelope is not a viewer projection. Keep neither
|
||||
-- the owner-only nor non-owner-only capability there; both durable boxes above
|
||||
-- remain authoritative for replay and history.
|
||||
UPDATE public.private_messages private_message
|
||||
SET media = private_message.media
|
||||
#- '{service_action,star_gift,prepaid_upgrade_hash}'
|
||||
#- '{service_action,star_gift,can_upgrade}'
|
||||
FROM star_gift_prepaid_viewer_sources source
|
||||
WHERE private_message.sender_user_id = source.message_sender_id
|
||||
AND private_message.id = source.private_message_id;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_prepaid_viewer_sources source
|
||||
JOIN public.message_boxes box
|
||||
ON box.message_sender_id = source.message_sender_id
|
||||
AND box.private_message_id = source.private_message_id
|
||||
AND NOT box.deleted
|
||||
WHERE (box.owner_user_id = source.owner_user_id AND
|
||||
box.media #> '{service_action,star_gift,prepaid_upgrade_hash}' IS NOT NULL)
|
||||
OR (box.owner_user_id = source.sender_user_id AND
|
||||
box.owner_user_id <> source.owner_user_id AND
|
||||
box.media #> '{service_action,star_gift,can_upgrade}' IS NOT NULL)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'star gift prepaid viewer projection repair did not converge';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
-- The original migration attempted to repair development-only message-box
|
||||
-- projections and could abort server startup when an aggregate no longer had a
|
||||
-- live owner box. Project policy forbids migrations for unpublished internal
|
||||
-- shapes. Keep the version slot so a database at version 162 can advance, but
|
||||
-- never inspect or mutate gift/message/PTS/outbox state here.
|
||||
SELECT 1;
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ func TestStarGiftLifecycleAggregatePostgres(t *testing.T) {
|
|||
replayedPurchase.Send.RecipientMessage.ID != purchased.Send.RecipientMessage.ID {
|
||||
t.Fatalf("purchase replay = %+v err %v", replayedPurchase, err)
|
||||
}
|
||||
verifyPrepaidViewerProjectionMigration(t, ctx, pool, purchased.Saved.ID, owner.ID, buyer.ID,
|
||||
verifyPrepaidViewerProjectionMigrationNoop(t, ctx, pool, purchased.Saved.ID, owner.ID, buyer.ID,
|
||||
purchased.Send.RecipientMessage.ID, purchased.Send.SenderMessage.ID)
|
||||
|
||||
target, price, err := lifecycle.PrepaidUpgradeTarget(ctx, ownerPeer, purchased.Saved.PrepaidUpgradeHash)
|
||||
|
|
@ -1534,7 +1534,7 @@ WHERE target_user_id=$1 AND pts=$2 AND event_type='edit_message'`, userID, pts).
|
|||
assertMigratedAction(payerUserID, payerPrepayMessageID, 0)
|
||||
}
|
||||
|
||||
func verifyPrepaidViewerProjectionMigration(
|
||||
func verifyPrepaidViewerProjectionMigrationNoop(
|
||||
t *testing.T,
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
|
|
@ -1582,6 +1582,28 @@ SET media=jsonb_set(jsonb_set(media,'{service_action,star_gift,can_upgrade}','tr
|
|||
WHERE sender_user_id=$1 AND id=$2`, messageSenderID, privateMessageID, hash); err != nil {
|
||||
t.Fatalf("restore stale prepaid shared message: %v", err)
|
||||
}
|
||||
var ownerMediaBefore, senderMediaBefore, sharedMediaBefore string
|
||||
if err := tx.QueryRow(ctx, `SELECT media::text FROM message_boxes WHERE owner_user_id=$1 AND box_id=$2`,
|
||||
ownerUserID, ownerMessageID).Scan(&ownerMediaBefore); err != nil {
|
||||
t.Fatalf("load owner media before retired migration: %v", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `SELECT media::text FROM message_boxes WHERE owner_user_id=$1 AND box_id=$2`,
|
||||
senderUserID, senderMessageID).Scan(&senderMediaBefore); err != nil {
|
||||
t.Fatalf("load sender media before retired migration: %v", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `SELECT media::text FROM private_messages WHERE sender_user_id=$1 AND id=$2`,
|
||||
messageSenderID, privateMessageID).Scan(&sharedMediaBefore); err != nil {
|
||||
t.Fatalf("load shared media before retired migration: %v", err)
|
||||
}
|
||||
var eventsBefore, outboxBefore int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_update_events WHERE user_id IN ($1,$2)`,
|
||||
ownerUserID, senderUserID).Scan(&eventsBefore); err != nil {
|
||||
t.Fatalf("count events before retired migration: %v", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox WHERE target_user_id IN ($1,$2)`,
|
||||
ownerUserID, senderUserID).Scan(&outboxBefore); err != nil {
|
||||
t.Fatalf("count outbox before retired migration: %v", err)
|
||||
}
|
||||
|
||||
migrationSQL, err := deploy.Migrations.ReadFile("migrations/0163_star_gift_prepaid_viewer_projection.up.sql")
|
||||
if err != nil {
|
||||
|
|
@ -1591,50 +1613,54 @@ WHERE sender_user_id=$1 AND id=$2`, messageSenderID, privateMessageID, hash); er
|
|||
t.Fatalf("apply prepaid viewer migration probe: %v", err)
|
||||
}
|
||||
|
||||
assertProjection := func(userID int64, messageID int, wantCanUpgrade bool, wantHash string, ptsBefore int) {
|
||||
t.Helper()
|
||||
var mediaJSON string
|
||||
var pts int
|
||||
if err := tx.QueryRow(ctx, `SELECT media::text,pts FROM message_boxes
|
||||
WHERE owner_user_id=$1 AND box_id=$2 AND NOT deleted`, userID, messageID).Scan(&mediaJSON, &pts); err != nil {
|
||||
t.Fatalf("load migrated viewer box %d/%d: %v", userID, messageID, err)
|
||||
}
|
||||
media, err := decodeMessageMedia(mediaJSON)
|
||||
action := privateStarGiftAction(media)
|
||||
if err != nil || action == nil || action.CanUpgrade != wantCanUpgrade || action.PrepaidUpgradeHash != wantHash {
|
||||
t.Fatalf("migrated viewer box %d/%d = %+v err=%v", userID, messageID, action, err)
|
||||
}
|
||||
if pts != ptsBefore+1 {
|
||||
t.Fatalf("migrated viewer box %d/%d pts=%d want=%d", userID, messageID, pts, ptsBefore+1)
|
||||
}
|
||||
var eventCount, outboxCount int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_update_events
|
||||
WHERE user_id=$1 AND pts=$2 AND event_type='edit_message' AND message_box_id=$3`, userID, pts, messageID).Scan(&eventCount); err != nil {
|
||||
t.Fatalf("load migrated viewer event: %v", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox
|
||||
WHERE target_user_id=$1 AND pts=$2 AND event_type='edit_message'`, userID, pts).Scan(&outboxCount); err != nil {
|
||||
t.Fatalf("load migrated viewer outbox: %v", err)
|
||||
}
|
||||
if eventCount != 1 || outboxCount != 1 {
|
||||
t.Fatalf("migrated viewer event/outbox counts=%d/%d", eventCount, outboxCount)
|
||||
}
|
||||
var ownerMediaAfter, senderMediaAfter, sharedMediaAfter string
|
||||
var ownerPtsAfter, senderPtsAfter int
|
||||
if err := tx.QueryRow(ctx, `SELECT media::text,pts FROM message_boxes WHERE owner_user_id=$1 AND box_id=$2`,
|
||||
ownerUserID, ownerMessageID).Scan(&ownerMediaAfter, &ownerPtsAfter); err != nil {
|
||||
t.Fatalf("load owner box after retired migration: %v", err)
|
||||
}
|
||||
assertProjection(ownerUserID, ownerMessageID, true, "", ownerPtsBefore)
|
||||
assertProjection(senderUserID, senderMessageID, false, hash, senderPtsBefore)
|
||||
|
||||
var sharedHash, sharedCanUpgrade *string
|
||||
if err := tx.QueryRow(ctx, `SELECT media #>> '{service_action,star_gift,prepaid_upgrade_hash}',
|
||||
media #>> '{service_action,star_gift,can_upgrade}' FROM private_messages WHERE sender_user_id=$1 AND id=$2`,
|
||||
messageSenderID, privateMessageID).Scan(&sharedHash, &sharedCanUpgrade); err != nil {
|
||||
t.Fatalf("load migrated shared viewer projection: %v", err)
|
||||
if err := tx.QueryRow(ctx, `SELECT media::text,pts FROM message_boxes WHERE owner_user_id=$1 AND box_id=$2`,
|
||||
senderUserID, senderMessageID).Scan(&senderMediaAfter, &senderPtsAfter); err != nil {
|
||||
t.Fatalf("load sender box after retired migration: %v", err)
|
||||
}
|
||||
if sharedHash != nil || sharedCanUpgrade != nil {
|
||||
t.Fatalf("shared viewer projection retained hash/can_upgrade: hash=%v can=%v", sharedHash, sharedCanUpgrade)
|
||||
if err := tx.QueryRow(ctx, `SELECT media::text FROM private_messages WHERE sender_user_id=$1 AND id=$2`,
|
||||
messageSenderID, privateMessageID).Scan(&sharedMediaAfter); err != nil {
|
||||
t.Fatalf("load shared media after retired migration: %v", err)
|
||||
}
|
||||
if ownerMediaAfter != ownerMediaBefore || senderMediaAfter != senderMediaBefore || sharedMediaAfter != sharedMediaBefore {
|
||||
t.Fatalf("retired migration changed message media")
|
||||
}
|
||||
if ownerPtsAfter != ownerPtsBefore || senderPtsAfter != senderPtsBefore {
|
||||
t.Fatalf("retired migration changed PTS: owner=%d/%d sender=%d/%d",
|
||||
ownerPtsBefore, ownerPtsAfter, senderPtsBefore, senderPtsAfter)
|
||||
}
|
||||
var eventsAfter, outboxAfter int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM user_update_events WHERE user_id IN ($1,$2)`,
|
||||
ownerUserID, senderUserID).Scan(&eventsAfter); err != nil {
|
||||
t.Fatalf("count events after retired migration: %v", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM dispatch_outbox WHERE target_user_id IN ($1,$2)`,
|
||||
ownerUserID, senderUserID).Scan(&outboxAfter); err != nil {
|
||||
t.Fatalf("count outbox after retired migration: %v", err)
|
||||
}
|
||||
if eventsAfter != eventsBefore || outboxAfter != outboxBefore {
|
||||
t.Fatalf("retired migration changed event/outbox counts: events=%d/%d outbox=%d/%d",
|
||||
eventsBefore, eventsAfter, outboxBefore, outboxAfter)
|
||||
}
|
||||
var storedHash string
|
||||
if err := tx.QueryRow(ctx, `SELECT prepaid_upgrade_hash FROM peer_star_gifts WHERE id=$1`, savedGiftID).Scan(&storedHash); err != nil || storedHash != hash {
|
||||
t.Fatalf("viewer migration changed aggregate hash=%q want=%q err=%v", storedHash, hash, err)
|
||||
t.Fatalf("retired migration changed aggregate hash=%q want=%q err=%v", storedHash, hash, err)
|
||||
}
|
||||
|
||||
// Reproduce the deployment shape that made the original 0163 abort. A
|
||||
// retired version slot must advance even when an aggregate has no live owner
|
||||
// box; it has no authority to classify or repair historical message state.
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM message_boxes WHERE owner_user_id=$1 AND box_id=$2`,
|
||||
ownerUserID, ownerMessageID); err != nil {
|
||||
t.Fatalf("remove owner box for retired migration probe: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, string(migrationSQL)); err != nil {
|
||||
t.Fatalf("retired migration rejected missing owner box: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("migrate star gift lifecycle schema: %v", err)
|
||||
}
|
||||
if status.Dirty || status.Empty || status.Version != 162 {
|
||||
t.Fatalf("migration status = %+v, want clean version 162", status)
|
||||
if status.Dirty || status.Empty || status.Version != 163 {
|
||||
t.Fatalf("migration status = %+v, want clean version 163", status)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue