diff --git a/deploy/migrations/0163_star_gift_prepaid_viewer_projection.down.sql b/deploy/migrations/0163_star_gift_prepaid_viewer_projection.down.sql index 76ba726c..e96ac9d5 100644 --- a/deploy/migrations/0163_star_gift_prepaid_viewer_projection.down.sql +++ b/deploy/migrations/0163_star_gift_prepaid_viewer_projection.down.sql @@ -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; diff --git a/deploy/migrations/0163_star_gift_prepaid_viewer_projection.up.sql b/deploy/migrations/0163_star_gift_prepaid_viewer_projection.up.sql index 22dd3388..c5dc14df 100644 --- a/deploy/migrations/0163_star_gift_prepaid_viewer_projection.up.sql +++ b/deploy/migrations/0163_star_gift_prepaid_viewer_projection.up.sql @@ -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; diff --git a/internal/store/postgres/star_gift_lifecycle_integration_test.go b/internal/store/postgres/star_gift_lifecycle_integration_test.go index 6320a25a..66921fe6 100644 --- a/internal/store/postgres/star_gift_lifecycle_integration_test.go +++ b/internal/store/postgres/star_gift_lifecycle_integration_test.go @@ -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) } } diff --git a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go index bed2fb9c..0c29aa67 100644 --- a/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go +++ b/internal/store/postgres/star_gift_lifecycle_migration_integration_test.go @@ -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) } }