Skip to content

Add Bundle ownership models to support games owned via large unmaterialized bundles #313

@leafo

Description

@leafo

Background

itch.io has historically sold very large bundles (e.g. the Bundle for Racial Justice and Equality, Bundle for Ukraine) containing thousands of games each. To avoid flooding the database, ownership of games inside these bundles is not materialized as DownloadKey rows at purchase time. Instead, the user's account owns a BundleDownloadKey per bundle purchase, and an individual DownloadKey is created on first access of a specific game.

This leaves a gap in the desktop app: a user who owns thousands of games via a bundle but hasn't installed any of them yet sees "Buy now" on every game page, because the renderer's getGameStatus only treats a game as owned when a DownloadKey is present. Fixing this needs (a) ownership inference from bundle membership and (b) lazy materialization of the real DownloadKey on first install.

Proposed design

Add first-class Bundle and BundleKey models in butler, synced from itch.io using the same lazyfetch + hades pattern that Collections already use. A BundleKey is the bundle analogue of OwnedKey: it wraps a Bundle and records the purchase that grants ownership. Use bundle membership as a second source of game ownership alongside DownloadKeys, and materialize a real DownloadKey on first install via a new server-side endpoint.

Scope is kept tight to what's needed for (a) the Install vs Buy fix and (b) the future library UI that lists owned bundles and shows a bundle detail page with a paginated game grid. The RPC shape is FetchProfileOwnedBundles + FetchBundleGames, with no separate single-bundle fetch: users only encounter bundles they own, so FetchProfileOwnedBundles already syncs the full Bundle metadata (embedded inside each BundleKey) into local DB and the bundle detail page can read its header straight from cache. The game grid is driven by paginated FetchBundleGames. No eager-load-all preload helpers are added: bundles can contain thousands of games, so pagination is the only correct access pattern. Renderer changes are out of scope and will follow in a separate issue.

Server API

All endpoints are v2 and require the profile:owned scope.

  • GET /profile/owned-bundles{ bundle_keys: [{ id, bundle_id, purchase_id, created_at, bundle: <Bundle> }, ...] }. Returns every bundle the current user owns, deduped across multiple purchases of the same bundle (most recent key wins). Currently excludes sale bundles. Capped at 100.
  • GET /bundles/:bundle_id/bundle-games?page=N&per_page=M{ page, per_page, bundle_games: [<BundleGame>, ...] }. Paginated game list for a bundle the current user owns.
  • POST /bundles/:bundle_id/claim-game with body { game_id }{ download_key: <DownloadKey> }. Idempotently materializes a real DownloadKey for a game owned via a bundle purchase. Safe to call multiple times.

go-itchio changes

New types:

  • Bundle
  • BundleGame join struct (mirrors CollectionGame, holding BundleID, GameID, position, per-game min price, timestamps)
  • BundleKey: mirrors DownloadKey/OwnedKey. Holds ID, BundleID, PurchaseID, CreatedAt, and an embedded Bundle *Bundle populated from the API response

New client methods:

  • ListProfileOwnedBundles(ctx) -> []*BundleKey (each with Bundle populated)
  • GetBundleGames(ctx, GetBundleGamesParams{BundleID, Page}) -> []*BundleGame
  • ClaimBundleGame(ctx, ClaimBundleGameParams{BundleID, GameID}) -> *DownloadKey: materializes a real DownloadKey at install time. Atomic and idempotent: safe to call multiple times, always returns the existing key if one was already claimed.

Butler changes

New models

Register the new types in database/models/all_models.go alongside the existing Collection entries:

&itchio.Bundle{},
&itchio.BundleGame{},
&itchio.BundleKey{},

The BundleKey table is what answers "which bundles does this profile own" — there is no separate ProfileBundle join, since BundleKey already carries the PurchaseID that scopes ownership to a user.

New files:

  • database/models/bundle_key_ext.go: provides one helper, ProfileOwnsGameViaBundle(conn, profileID, gameID int64) bool, a single JOIN of bundle_games against bundle_keys filtered by the profile's purchases

Freshness targets

In database/models/freshness.go, add:

  • FetchTargetForProfileOwnedBundles(profileID)
  • FetchTargetForBundleGames(bundleID): use longTTL like FetchTargetForCollectionGames

New RPC handlers

  • endpoints/fetch/fetch_profileownedbundles.go (modeled on fetch_profilecollections.go). The persistence step must explicitly null out any inline BundleGames slice on each Bundle embedded inside a BundleKey from the API response before saving, so the locally-paginated bundle_games table is not clobbered by partial inline data.
  • endpoints/fetch/fetch_bundlegames.go (modeled on fetch_collectiongames.go). Paginated; this is the only correct access pattern for the bundle's game list and is what the renderer's carousel/grid will read from.

Both use the same lazyfetch + hades persistence pattern as their Collection counterparts.

Existing code touch points

1. AccessForGameID() in cmd/operate/game_utils.go

After the existing DownloadKeysByGameID() check and before falling back to the bare API key path, call ProfileOwnsGameViaBundle(...). On hit, return an Access whose Credentials indicate bundle ownership without a materialized DownloadKeyID yet. Suggested credential shape:

type GameCredentials struct {
    // existing fields...
    DownloadKeyID int64
    BundleID      int64 // new: nonzero means "owned via bundle, not yet materialized"
}

2. Materialization gate in install flow

Before client.ListGameUploads() in endpoints/install/install_queue.go (and any other path that needs DownloadKeyID > 0 to fetch a download URL), insert:

if creds.DownloadKeyID == 0 && creds.BundleID != 0 {
    key, err := client.ClaimBundleGame(ctx, ClaimBundleGameParams{
        BundleID: creds.BundleID,
        GameID:   creds.GameID,
    })
    // persist via the same hades path as fetch_profileownedkeys.go
    models.MustSave(conn, fakeProfile, hades.Assoc("OwnedKeys", hades.Assoc("Game")))
    creds.DownloadKeyID = key.ID
}

After materialization the game becomes indistinguishable from any other directly-owned game. Subsequent installs/updates use the normal DownloadKey code path with no special casing.

3. FetchCommons extension

endpoints/fetch/fetch_commons.go should return one new field on FetchCommonsResult:

BundleGameIDs []int64

populated from a single bundle_games JOIN bundle_keys query scoped to the current profile's purchases. This is the field the renderer will read to drive the Install vs Buy CTA without needing per-game ownership queries.

Update flow

endpoints/update/update.go's ownership-log check should stay non-materializing. "Owned via bundle, key not yet claimed" is a legitimate state, and update checks shouldn't force a server round trip just to populate a DownloadKeyID. Materialization only happens at install time.

Out of scope (separate issue)

  • Renderer changes to getGameStatus, commons reducer, and library UI for bundle attribution
  • Any bundle-aware UI (badges, "Owned via bundles" library tab, bundle detail pages), addressable once butler and the new FetchCommons field are in place

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels
    No fields configured for Feature.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions