Back to Hydra

Grouped Achievement Souvenirs

docs/grouped-achievement-souvenirs.md

4.1.238.4 KB
Original Source

Grouped Achievement Souvenirs

Goal

When several achievements are unlocked during the same gameplay moment, Hydra should create one souvenir using one captured frame and associate every achievement from that unlock batch with it.

Example card title:

Giant Slayer + 4 others

Hiding, deleting, or liking this card applies to the complete captured moment rather than to one achievement inside it.

Current implementation

This design is based on the launcher worktree and the API develop branch inspected on 2026-08-19.

The API does not currently have a separate souvenir entity. A souvenir is implicitly a GameAchievement with a non-null imageKey:

  • GameAchievement owns imageKey, souvenirVisibility, and likeCount.
  • GameAchievementLike references one GameAchievement.
  • GET /users/:id/souvenirs queries achievements with images and returns one feed item per achievement.
  • Like, visibility, and deletion endpoints identify a souvenir using gameId + achievementName.
  • PUT /profile/games/achievements accepts an imageKey on each achievement.

The launcher similarly identifies souvenirs using gameId + achievementName. Its regular achievement pipeline receives all newly detected achievements as an array, but captures and uploads one image for each achievement sequentially.

Why sharing an image key is unsafe

Assigning one imageKey to multiple existing GameAchievement rows would still create multiple cards, like counters, visibility settings, and like collections. Deleting any one of those achievements would queue the shared object for storage deletion and break the remaining cards.

A shared captured moment therefore needs to become a first-class entity.

Proposed API data model

An achievement can only be unlocked once and currently supports no more than one souvenir, so a nullable foreign key on GameAchievement is sufficient. A many-to-many join table is unnecessary unless Hydra later allows multiple souvenirs for the same achievement.

prisma
model Souvenir {
  id                     Int                @id @default(autoincrement())
  gameId                 Int
  clientId               String
  imageKey               String             @unique
  capturedAt             DateTime
  visibility             ProfileVisibility @default(PRIVATE)
  likeCount              Int                @default(0)
  rarestUnlockPercentage Float?
  achievementOrder       String[]
  createdAt              DateTime           @default(now())
  updatedAt              DateTime           @updatedAt

  game         Game              @relation(fields: [gameId], references: [id], onDelete: Cascade)
  achievements GameAchievement[]
  likes        SouvenirLike[]

  @@unique([gameId, clientId])
  @@index([gameId, capturedAt, id])
  @@index([gameId, rarestUnlockPercentage, capturedAt, id])
}

model SouvenirLike {
  id         Int      @id @default(autoincrement())
  souvenirId Int
  userId     Int
  createdAt  DateTime @default(now())

  souvenir Souvenir @relation(fields: [souvenirId], references: [id], onDelete: Cascade)
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([souvenirId, userId])
  @@index([userId])
}

/// No relations: cleanup metadata must survive hard game/account deletion until
/// an unclaimed object has been queued for deletion.
model SouvenirUpload {
  id        String    @id @default(uuid())
  userId    Int
  gameId    Int
  clientId  String
  imageKey  String    @unique
  contentLength Int?
  expiresAt DateTime
  claimedAt DateTime?
  deletedAt DateTime?
  createdAt DateTime  @default(now())

  @@unique([gameId, clientId])
  @@index([claimedAt, expiresAt])
  @@index([userId])
}

model GameAchievement {
  // Existing achievement fields...

  souvenirId Int?
  souvenir   Souvenir? @relation(fields: [souvenirId], references: [id], onDelete: SetNull)
}

model Game {
  // Existing game fields...

  souvenirs Souvenir[]
}

model User {
  // Existing user fields...

  souvenirLikes SouvenirLike[]
}

clientId is generated by the launcher and provides idempotency. Retrying the same batch after a timeout must return or preserve the same souvenir rather than create a duplicate.

imageKey is also unique. Prefix ownership validation alone is insufficient because the same user could otherwise submit one owned key with two different client IDs. Deleting either souvenir would then delete the object used by the other. Before enabling the unique constraint, run a production preflight that reports duplicate and foreign legacy image keys and block the rollout until every conflict is repaired.

The association is immutable after creation:

  • An achievement with a null souvenirId can be attached to a new souvenir.
  • An achievement already attached to that same souvenir is an idempotent retry.
  • An achievement attached to any other souvenir is a conflict and must never be silently moved.

achievementOrder stores the associated achievements' canonical database names in the normalized input order. It provides a stable fallback when several achievements have identical unlock timestamps without adding ordered metadata to the child relation. Before directly deleting a souvenir, the service clears souvenirId on every associated achievement in the same transaction, then deletes the souvenir so the achievement rows remain intact.

Achievement synchronization contract

Extend the existing achievement synchronization request with an optional souvenirs array:

json
{
  "id": "encoded-game-id",
  "achievements": [
    {
      "name": "ACH_FIRST",
      "unlockTime": 1787054400000
    },
    {
      "name": "ACH_SECOND",
      "unlockTime": 1787054400000
    }
  ],
  "souvenirs": [
    {
      "clientId": "launcher-generated-uuid",
      "imageKey": "achievements/42/image.jpeg",
      "capturedAt": 1787054400000,
      "achievementNames": ["ACH_FIRST", "ACH_SECOND"]
    }
  ]
}

Using an array allows the launcher to retry several pending captured moments in one synchronization request.

The validation schema must reject empty souvenir batches and define bounded lengths for the array, clientId, imageKey, and achievementNames. Within one request, client IDs must be unique, achievement names must be unique case-insensitively within each souvenir, and one achievement cannot appear in two submitted souvenirs. New-launcher clientId values are UUIDs; deterministic legacy IDs used by the API migration are not accepted from clients. Invalid timestamps, duplicate names, souvenir-referenced names missing from the catalogue, and image keys outside the authenticated user's prefix are request errors rather than silently ignored input. Unreferenced legacy achievement entries retain the current catalogue-filtering behavior.

Before opening the database transaction, validate catalogue membership and user-key prefixes, load the upload reservations, and issue parallel R2 HeadObject checks for every unclaimed key. The object must exist and its content length must match the reservation before it can become a souvenir. A missing object returns a retryable upload-incomplete error so the launcher repeats the PUT; do not keep an external storage call open inside the database transaction.

Within the existing database transaction, the API should:

  1. Synchronize the achievements as it does today.
  2. Validate that every listed achievement exists in the same game and catalogue.
  3. Lock and revalidate each SouvenirUpload reservation against the user, game, client ID, image key, expiry, claim, and deletion state. It must be unclaimed and unexpired for a new souvenir; an idempotent retry may use the already-claimed matching reservation for the existing souvenir.
  4. Reject an image key already claimed by another souvenir, even when both souvenirs belong to the same user.
  5. Load or create each souvenir using (gameId, clientId) without overwriting stored fields during the create race.
  6. For an existing souvenir, compare the image key and ordered, case-normalized achievement list. Reject any conflict.
  7. Reject any achievement already associated with a different souvenir; never reassign it implicitly.
  8. Associate the corresponding GameAchievement rows and persist their canonical names in achievementOrder.
  9. Set rarestUnlockPercentage to the lowest known percentage among the associated achievements, or null when all values are unknown.
  10. Mark the matching upload reservation as claimed.
  11. Return an acknowledgement for every submitted client ID.

Souvenir synchronization is atomic with achievement synchronization. If one submitted souvenir conflicts or fails validation, the transaction rejects the entire request and acknowledges none of them. Partial success is not supported in the first implementation. Item-specific validation/conflict errors include the offending clientId; the launcher marks only that record terminal and retries the remaining unacknowledged records. For a request-level error without a client ID, retry records individually before classifying any of them as terminal.

When a request includes souvenirs, a successful response is always 200, including a retry where every achievement and souvenir was already stored:

json
{
  "objectId": "620",
  "shop": "steam",
  "achievements": [
    {
      "name": "ACH_FIRST",
      "unlockTime": 1787054400000,
      "hardcoreUnlockTime": null
    },
    {
      "name": "ACH_SECOND",
      "unlockTime": 1787054400000,
      "hardcoreUnlockTime": null
    }
  ],
  "souvenirs": [
    {
      "clientId": "launcher-generated-uuid",
      "id": "encoded-souvenir-id"
    }
  ]
}

achievements contains the canonical server achievement collection, matching the payload returned by the existing reconciliation 200 response. Requests without the new souvenirs field retain the existing 200/204 behavior for old launcher versions. The new launcher replaces its local canonical collection from achievements and clears only the client IDs present in the acknowledgement.

New clients should use the souvenirs collection. The existing per-achievement imageKey input can temporarily remain supported for older launchers through a compatibility adapter:

  • For an unassociated achievement, create a single-achievement souvenir with deterministic client ID legacy:<gameAchievementId>, capturedAt equal to the achievement's unlock time, and achievementOrder containing its canonical stored name. Also create a matching claimed SouvenirUpload idempotency record for the legacy key.
  • Repeating the same key is a no-op.
  • Replacing the key on a legacy single-achievement souvenir preserves current behavior: queue the old key in the transaction, delete its likes, reset likeCount, and update both the souvenir and claimed upload-record key.
  • A different legacy key submitted for an achievement that belongs to a grouped souvenir must not replace the group image, detach the achievement, or delete grouped likes. Treat the achievement synchronization as successful so the old launcher clears its pending entry. If the submitted key is otherwise unclaimed, queue that unused upload for deletion; if another souvenir already claims it, leave it untouched.

Monitor use of the legacy adapter and do not remove it until launcher-version telemetry shows that unsupported clients are no longer active.

Souvenir list response

GET /users/:id/souvenirs should paginate Souvenir rows rather than GameAchievement rows. total must count captured moments.

json
{
  "items": [
    {
      "id": "encoded-souvenir-id",
      "imageUrl": "https://example.com/image.jpg",
      "capturedAt": 1787054400000,
      "visibility": "PUBLIC",
      "primaryAchievementName": "ACH_SECOND",
      "gameId": "b2c3d4e5",
      "objectId": "620",
      "shop": "steam",
      "gameTitle": "Portal 2",
      "gameIconUrl": "https://example.com/game.jpg",
      "achievements": [
        {
          "name": "ACH_FIRST",
          "displayName": "Giant Slayer",
          "description": "Defeat the first boss.",
          "achievementIcon": "https://example.com/achievement.jpg",
          "unlockTime": 1787054400000,
          "points": 45,
          "isRare": false,
          "isPlatinum": false
        },
        {
          "name": "ACH_SECOND",
          "displayName": "Speed Runner",
          "description": "Complete the level quickly.",
          "achievementIcon": "https://example.com/achievement-2.jpg",
          "unlockTime": 1787054400000,
          "points": 25,
          "isRare": true,
          "isPlatinum": false
        }
      ],
      "likeCount": 3,
      "likedByMe": false
    }
  ],
  "total": 1,
  "hiddenReason": null
}

primaryAchievementName is required and identifies one entry in achievements selected by the server rules below. visibility remains owner-only, matching the current response. For the transition period, the API also returns that primary achievement in the existing top-level achievement fields so older launcher versions continue displaying grouped souvenirs as a single-achievement card.

The first-class entity also changes two existing achievement reads that are separate from the souvenir feed:

  • The recent-achievements response in UsersService.getUserAchievements should return at most one compatibility item per souvenir, using the primary achievement and the souvenir image.
  • The per-game unlocked-achievements response in UsersService.getUnlockedAchievements should join through GameAchievement.souvenir. Each associated achievement may return the shared image URL when the souvenir and section are visible to the requester.

These reads must be migrated before the legacy GameAchievement.imageKey and souvenirVisibility columns are removed.

Sorting and visual state

  • recent uses Souvenir.capturedAt DESC, Souvenir.id DESC.
  • oldest uses Souvenir.capturedAt ASC, Souvenir.id ASC.
  • rare uses rarestUnlockPercentage ASC NULLS LAST, capturedAt DESC, id DESC so pagination remains database-driven and deterministic.
  • A souvenir is platinum if it contains the achievement that completed the game.
  • Otherwise, a souvenir uses the rare visual treatment if any associated achievement is rare.

The primary achievement displayed on the card should be selected consistently:

  1. The platinum achievement, when present.
  2. Otherwise the rarest achievement.
  3. Otherwise the first entry in achievementOrder.
  4. Finally the lowest achievement ID as a defensive tie-breaker for malformed legacy data.

The ordered achievements response follows achievementOrder, with achievement ID as a defensive fallback for inconsistent legacy data. The API must not depend on the implicit order of a Prisma relation.

When the game's unlocked count reaches the current catalogue total, determine the completing achievement by latest unlock time, then latest entry in achievementOrder within the same captured batch, then highest achievement ID. Only that achievement receives isPlatinum: true; a souvenir containing it selects it as primary. This makes a grouped final-unlock batch deterministic.

rarestUnlockPercentage is maintained data, not a write-once snapshot. Recompute it whenever associations are created and whenever an associated GameAchievement.globalUnlockPercentage changes. Update the existing rarity backfill script to refresh affected souvenirs as well. The value is the minimum known percentage and remains null only when every associated percentage is null; rare sorting places nulls last.

API actions

New actions should identify the captured moment directly:

text
POST   /users/:ownerId/souvenirs/:souvenirId/like
PATCH  /profile/souvenirs/:souvenirId/visibility
DELETE /profile/souvenirs/:souvenirId

Required related changes:

  • Add encoded souvenir ID helpers and validation.
  • Move the Redis like lock from gameAchievementId to souvenirId.
  • Move like notifications from the current gameId:achievementName metadata key to souvenirId.
  • Store achievementCount and optional primary achievement metadata in like notification variables.
  • Reuse the current durable souvenir object-deletion queue when deleting a souvenir.
  • Fully document the new and compatibility routes, idempotent upload authorization, and cleanup-cron behavior in Swagger.
  • Prefer a dedicated NestJS souvenir controller/service/module instead of adding more business logic to UsersService.

During compatibility rollout, the old gameId + achievementName endpoints can resolve the achievement's souvenirId and mutate the entire souvenir.

Notification variables use the encoded souvenir ID. Idempotently migrate existing non-deleted souvenir-like notifications by resolving their legacy encoded gameId:achievementName key through the backfilled achievement association. Retain legacy fields for display compatibility, but aggregation lookup uses the new ID so the next like does not create a duplicate notification.

Every ID-based mutation must load the souvenir through its game and verify that game.userId is the authenticated owner. Preserve the current subscription/session rule: when the owner has no active cloud subscription, every associated achievement must belong to the current authenticated session before visibility or deletion can be changed. The public like route must retain the current active-subscription, profile-section visibility, per-souvenir visibility, blocked-user, and owner checks.

After the read cutover, visibility updates, like toggles, and deletion use the new souvenir tables as their source of truth. Compatibility routes delegate to the same service methods so behavior cannot diverge; only the temporary expand-phase deployment mirrors changes into legacy columns/tables. Souvenir deletion queues its unique image key, clears souvenirId on its achievements, marks the upload reservation deleted, and deletes the database row in one transaction; best-effort object processing runs only after commit.

Launcher capture pipeline

Replace the current per-achievement capture map with a pending captured-moment record:

ts
interface PendingSouvenir {
  clientId: string;
  ownerId: string;
  remoteGameId: string;
  gameKey: string;
  screenshotPath: string;
  imageKey?: string;
  uploadedAt?: number;
  capturedAt: number;
  achievements: Array<{
    name: string;
    unlockTime: number;
    hardcore?: boolean;
  }>;
  status: "pending" | "terminal";
  attemptCount: number;
  lastAttemptAt?: number;
  lastErrorCode?: string;
}

interface LocalSouvenirAsset {
  souvenirId: string;
  clientId: string;
  ownerId: string;
  gameKey: string;
  screenshotPath: string;
}

Store pending moments as a collection keyed by clientId, not as one value per game. Multiple watcher passes for the same game can be pending simultaneously without overwriting one another. The record contains the complete achievement synchronization data so it remains retryable after process restart without depending on an in-memory achievement cache. Derive the API's achievementNames from this stored array. ownerId is the encoded authenticated user ID and scopes retries to the account that captured and authorized the upload; never retry a record while a different user is authenticated.

The new upload authorization is idempotent as well. Extend the achievement-image presigned URL contract with remoteGameId, launcher clientId, and the JPEG byte length; verify that the authenticated user owns the game, and create a server-generated owned JPEG key such as achievements/<userId>/<gameId>/<uploadUuid>.jpeg. Creating a new reservation requires an active subscription, but refreshing the same authenticated user's existing reservation remains allowed if the subscription expires while a captured moment is pending. Repeating authorization for an active unclaimed reservation returns the same image key with a fresh presigned URL only when the content length matches; a mismatch is an idempotency conflict. A claimed reservation returns its existing key and claimed status without authorizing an overwrite. Rate-limit new reservations with a named RedisExpirationTime entry and cap outstanding unclaimed reservations per user; retries of an existing client ID do not consume another slot. The reservation expiry uses a named non-Redis constant exported from src/constants. The legacy random-key authorization remains available to old launchers.

The synchronization transaction claims the reservation when it creates the souvenir. A cleanup job locks expired reservations and, only while they are still unclaimed, queues their keys through the existing durable object-deletion queue and removes the reservation metadata in the same transaction. A concurrent claim or refresh therefore wins cleanly rather than deleting an active upload. A later authorization for the same client ID creates a fresh reservation with a different image key, so delayed deletion of the expired key cannot remove the new upload. Claimed reservations remain as idempotency records. Deleting their souvenir sets deletedAt instead of removing the reservation, and authorization/synchronization reject a deleted reservation so a delayed retry cannot resurrect a user-deleted card.

For regular games:

  1. Detect the new achievement array.
  2. Capture one frame before displaying achievement notifications.
  3. Encode and save that frame once using the generated clientId.
  4. Persist the pending batch before starting network work.
  5. Display achievement notifications without waiting for upload or synchronization.
  6. Request or resume upload authorization using remoteGameId and clientId.
  7. Persist the returned imageKey before uploading bytes. If it differs from the stored key because an expired reservation was rotated, clear uploadedAt first.
  8. When the reservation is unclaimed and uploadedAt is absent, upload the image to that key and persist uploadedAt after the PUT succeeds. A repeated PUT during the same reservation may resend bytes but targets the same logical object.
  9. Synchronize all achievements and the one souvenir association.
  10. When the API acknowledgement contains that exact clientId, atomically move its local path into a LocalSouvenirAsset record keyed by encoded souvenir ID and remove the pending record.

Capture or local persistence failure logs the souvenir error but does not block achievement notifications, local achievement state, or achievement-only synchronization. Once a pending record is durable, upload/API failures affect only its retry lifecycle and do not replay notifications.

The pending worker retries records for the current authenticated user with bounded exponential backoff:

  • after API authentication and subscription state are restored during startup;
  • when connectivity returns;
  • after a new pending record is created; and
  • after a retryable upload or synchronization failure.

Each retry refreshes or resolves the reservation before deciding whether to upload or synchronize. If the returned key matches and uploadedAt is set, retry only synchronization. If upload completion is unknown, repeat the PUT to the active key. If expiry cleanup rotated the reservation, persist the new key, clear uploadedAt, and upload again. Validation and idempotency-conflict responses set status to terminal: retain lastErrorCode, stop automatic retries, and log the client ID without deleting or reassigning server data. Network failures, timeouts, upload-incomplete responses, rate/reservation-capacity limits, rollout-disabled responses, and server errors remain retryable. Keep terminal records and their screenshots for a named diagnostic-retention period; afterward delete the local file and record. The API's expired-reservation cleanup independently handles any unclaimed remote object.

Screenshot cleanup must receive the set of pending screenshotPath values and exclude them from the 50-file retention limit. Acknowledged files become eligible for normal cleanup; when cleanup removes one, it also removes the corresponding LocalSouvenirAsset mapping. Deleting a souvenir locally looks up its encoded souvenir ID in that mapping and removes the recorded path when present. A souvenir captured on another device simply has no local asset. Local deletion never derives a path from an individual achievement name.

The first implementation defines one per-game watcher polling pass as one souvenir batch. The current Windows and Wine loops can call mergeAchievements separately for each changed achievement file, so split change detection/parsing from merging: collect and case-insensitively deduplicate every newly parsed achievement for a game, then invoke mergeAchievements once after all changed files in that pass are read. Guard the complete per-game pass so another poll cannot enter while capture, persistence, or merging is in flight. A short per-game coalescing window can be added later if testing finds games that split one logical burst across adjacent polling passes.

For emulator integrations:

  • DuckStation and PCSX2 should group all unlock lines read during one polling pass and capture one frame.
  • RetroArch should group new auto-screenshot files found during one polling pass, retain one selected frame, and remove the unused source files after successful import.
  • Async interval callbacks must not overlap. Use an in-flight guard or an awaited loop per game.

Launcher UI and state

Introduce a souvenir-specific type instead of representing feed entries as ProfileAchievement:

ts
interface ProfileSouvenir {
  id: string;
  imageUrl: string | null;
  capturedAt: number;
  primaryAchievementName: string;
  achievements: ProfileSouvenirAchievement[];
  visibility?: ProfileVisibility;
  gameId: string;
  objectId: string;
  shop: GameShop;
  gameTitle: string | null;
  gameIconUrl: string | null;
  likeCount: number;
  likedByMe: boolean;
}

Required UI changes:

  • Use souvenir.id for list keys, optimistic updates, carousel selection, loading states, likes, visibility, and deletion.
  • Display the primary achievement followed by a localized + {{count}} other/others label.
  • Show all associated achievements in the lightbox, using a compact list or a collapsible section for large batches.
  • Apply like, hide, and delete actions to the complete souvenir.
  • Preserve one card and one carousel page per captured moment.
  • Update both desktop and Big Picture implementations.
  • Update English and Brazilian Portuguese translations with correct singular and plural forms.

Reset and deletion behavior

Because one image represents the complete group, resetting any associated achievement deletes the complete souvenir before deleting the achievement. This preserves the rule that a souvenir is an indivisible captured moment and avoids leaving a grouped card whose captured context no longer matches its contents.

Resetting all achievements for a game must delete all of that game's souvenirs, queue each unique image key once, and remove associated likes through cascading relations.

Local screenshot deletion should use the souvenir clientId or stored local screenshot path rather than an achievement name.

Database cascades do not delete R2 objects. Every path that deletes games or achievements must first select the affected Souvenir.imageKey values and queue them in the same transaction. This includes:

  • single-achievement and all-achievement resets;
  • direct souvenir deletion;
  • RetroAchievements disconnect/account replacement;
  • OAuth integration disconnects that hard-delete imported games; and
  • account deletion, which may continue using the existing user-prefix deletion queue.

Game, integration, and account deletion paths must also collect unclaimed SouvenirUpload.imageKey values. Once their object or user-prefix deletion is durably queued and the owning game/account is being removed, delete all corresponding reservation metadata because ownership validation prevents replay. Direct souvenir deletion keeps its claimed reservation and sets deletedAt because the game remains valid and a delayed retry must not recreate the card.

The launcher must cancel local pending batches before issuing a reset. Resetting one achievement cancels the complete pending batch containing it; resetting all achievements cancels every pending batch for that game. Remove the protected pending record before making its screenshot eligible for local cleanup so a retry cannot recreate progress after the user resets it. Any already-uploaded unclaimed object is removed later by reservation expiry cleanup.

Use the existing deletion helper's deduplication so a key is queued once even when several achievements or games are removed together. Add regression tests proving that a failed database transaction does not process an object deletion and a committed transaction remains recoverable through the durable queue.

Migration plan

  1. Run a read-only preflight over legacy rows. Report foreign image keys, duplicate non-null image keys, counter-versus-like-row mismatches, and achievements that do not resolve to an owned game. Block the migration until duplicate/foreign keys are repaired.
  2. Add Souvenir, SouvenirLike, SouvenirUpload, and nullable GameAchievement.souvenirId without removing legacy columns. Include the explicit game/user relations and cascade behavior from the proposed schema.
  3. Deploy an expand-phase API with grouped writes disabled and reads still using legacy columns. Every legacy image sync, replacement, like, visibility change, deletion, and like-notification aggregation lazily ensures and dual-writes its single-achievement souvenir while retaining legacy display variables; deletion and integration cleanup paths understand both schemas. This closes races while the backfill is running.
  4. Backfill one souvenir for every existing GameAchievement with a non-null imageKey using:
    • clientId = legacy:<gameAchievementId>;
    • capturedAt = GameAchievement.unlockTime;
    • visibility = GameAchievement.souvenirVisibility;
    • rarestUnlockPercentage = GameAchievement.globalUnlockPercentage; and
    • achievementOrder = [GameAchievement.name].
    • Create the matching SouvenirUpload idempotency record with the same user, game, client ID, and image key, setting both claimedAt and expiresAt to the migration time and leaving the legacy-unknown contentLength null; expiry and length are ignored after claim.
  5. Migrate GameAchievementLike rows with conflict-safe inserts keyed by (souvenirId, userId).
  6. Recompute every new Souvenir.likeCount from migrated SouvenirLike rows. Report any difference from the legacy counter rather than carrying inconsistent denormalized state forward silently.
  7. Run a catch-up pass, then verify that every legacy row with an image has exactly one associated souvenir, every new key has the correct owner prefix, no image key is duplicated, and source/target like counts match. Because step 3 dual-writes live mutations, a stable clean verification is the cutover gate.
  8. Idempotently migrate active souvenir-like notification variables from the legacy gameId:achievementName key to the encoded backfilled souvenir ID, retaining old display variables during compatibility.
  9. Switch API reads and primary writes to the new tables while continuing to accept old launcher payloads and routes. Migrate the recent-achievement, per-game achievement, reset, OAuth deletion, RetroAchievements deletion, notification, upload-authorization, audit, and rarity-backfill consumers listed above.
  10. Enable grouped API writes behind the rollout flag.
  11. Release launcher support for grouped souvenirs, durable retry, and ID-based actions.
  12. Monitor backfill integrity, terminal pending records, object deletion failures, duplicate client IDs, idempotency conflicts, and legacy route/payload usage.
  13. Remove legacy image, visibility, like columns, likes table, dual-write code, and compatibility routes only after unsupported launcher versions are no longer active.

The backfill and compatibility behavior must be idempotent and safe to rerun. Use deterministic client IDs plus conflict-safe inserts, and paginate by a stable achievement ID cursor rather than offset. A dry run reports intended inserts, associations, migrated likes, mismatches, and invalid rows; write mode must stop on an invariant violation rather than partially guessing a repair.

Keep the legacy columns during the complete compatibility window. The new tables are the source of truth after step 9; rolling the API back to code that only understands legacy rows is not safe once grouped souvenirs are accepted. If rollback is required before step 10, disable grouped writes and return a retryable service-unavailable response so new launchers retain pending batches. Disable the flag before any API rollback.

Test plan

API

  • One achievement creates one souvenir with one association.
  • Five achievements and one image create one souvenir with five associations.
  • Retrying the same clientId does not duplicate the souvenir or likes.
  • Reusing a clientId with conflicting data is rejected.
  • Reusing one imageKey with a different clientId is rejected, including concurrent requests.
  • An achievement already associated with another souvenir cannot be reassigned.
  • Duplicate client IDs, duplicate case-insensitive achievement names, cross-batch achievement reuse, empty groups, oversized arrays, and invalid timestamps are rejected.
  • Foreign image keys are rejected.
  • Missing uploads and content-length mismatches cannot be claimed as souvenirs; storage checks occur before the database transaction.
  • Repeating active upload authorization returns the same image key and a fresh usable URL; expired reservations rotate to a different key only after the old key is durably queued for deletion.
  • Reusing an active upload client ID with a different byte length is rejected.
  • Claimed and deleted reservations never return a PUT URL that could overwrite or resurrect the stored souvenir.
  • New upload reservations require subscription and game ownership, while an existing owned reservation can finish after subscription expiry; retries do not consume another outstanding slot.
  • Expired unclaimed upload reservations queue their keys for deletion, while claimed and deleted reservations enforce retry idempotency and prevent resurrection.
  • A retry containing already-synchronized achievements still returns 200 and acknowledges every submitted clientId.
  • A multi-souvenir request is all-or-nothing when one item conflicts.
  • Item-specific conflicts identify their client ID so valid unacknowledged records can be retried separately.
  • Legacy image replacement queues the previous image, deletes its likes, and resets the counter.
  • A legacy payload cannot replace or split an existing grouped souvenir.
  • Pagination totals and offsets count souvenirs rather than achievements.
  • Recent, oldest, and rare sorting operate on grouped souvenirs.
  • Equal unlock timestamps retain deterministic achievementOrder order.
  • A grouped final-unlock batch identifies one deterministic platinum achievement and primary achievement.
  • Rarity refresh updates rarestUnlockPercentage and rare sorting; all-null groups sort last.
  • Visibility filters apply to the entire souvenir.
  • Likes are unique per user and update the grouped counter transactionally.
  • Like notifications aggregate by souvenir ID.
  • Migrated legacy like notifications continue aggregating into the existing notification instead of creating a duplicate.
  • Deletion queues one image and removes all souvenir likes.
  • Resetting any associated achievement deletes the complete souvenir.
  • Non-cloud ID mutations preserve current-session restrictions.
  • Recent-achievement and per-game achievement responses resolve images from the new souvenir relation.
  • OAuth and RetroAchievements deletion paths queue souvenir keys before deleting games or achievements.
  • Game and account deletion queue unclaimed upload keys and clean up reservation metadata without losing the durable deletion record.
  • Existing single-achievement souvenirs are preserved by migration.
  • Migration dry run reports invalid rows, migration write mode is idempotent, and rerunning it creates no duplicate souvenirs or likes.
  • Legacy sync, like, notification, visibility, and deletion mutations racing the backfill remain consistent through the expand-phase dual write and catch-up verification.
  • Legacy upload and action routes remain functional during rollout.

Launcher

  • Five achievements detected together cause one capture, one upload, and one souvenir request.
  • Capture or pending-store failure still records and synchronizes the achievements once without a souvenir.
  • A failed upload leaves a durable pending batch and retries safely.
  • A successful retry cannot create duplicate souvenirs.
  • Restarting after capture, after upload, and after an API timeout resumes without recapturing; an upload with unknown completion may repeat the PUT to the active key, while an expired reservation safely rotates after queuing the old object.
  • Restart recovery reconstructs the complete achievement payload from the pending record without requiring the in-memory achievement cache.
  • Two pending moments for one game coexist without overwriting one another.
  • Pending moments are retried only for the account that captured them.
  • Screenshot cleanup retains pending files even when more than 50 screenshots exist.
  • Acknowledgements clear only matching client IDs; missing IDs and terminal conflicts do not loop indefinitely.
  • Acknowledgement atomically moves the screenshot path into the souvenir-ID asset mapping; deletion and cleanup remove the correct local file and mapping.
  • Terminal records retain diagnostics and their screenshot until the named retention period, then clean up locally.
  • Single and all-achievement resets cancel matching pending batches so they cannot replay deleted progress.
  • Watcher passes cannot process the same game concurrently.
  • Two achievement files changed for one game during the same polling pass are merged into one captured batch.
  • Regular, DuckStation, PCSX2, and RetroArch flows group batches correctly.
  • Cards display singular and plural other/others labels correctly.
  • Cards use the server-selected primaryAchievementName, including platinum and multiple-rare-achievement cases.
  • The lightbox displays every achievement in the group.
  • Like, hide, delete, navigation, and optimistic state use the souvenir ID.
  • Desktop and Big Picture behavior remain equivalent.

Visibility defaults

New souvenir rows and newly created users default to PRIVATE. Update the Prisma schema and database defaults for Souvenir.visibility, User.souvenirsVisibility, and the legacy GameAchievement.souvenirVisibility column retained during compatibility. The backfill preserves stored visibility values for existing souvenirs, and the migration does not silently rewrite an existing user's profile-wide choice. Any retroactive privacy change for existing users requires a separate, explicit product migration.