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:
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
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
DownloadKeyrows at purchase time. Instead, the user's account owns aBundleDownloadKeyper bundle purchase, and an individualDownloadKeyis 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
getGameStatusonly treats a game as owned when aDownloadKeyis present. Fixing this needs (a) ownership inference from bundle membership and (b) lazy materialization of the realDownloadKeyon first install.Proposed design
Add first-class
BundleandBundleKeymodels in butler, synced from itch.io using the same lazyfetch + hades pattern that Collections already use. ABundleKeyis the bundle analogue ofOwnedKey: it wraps aBundleand records the purchase that grants ownership. Use bundle membership as a second source of game ownership alongsideDownloadKeys, and materialize a realDownloadKeyon 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, soFetchProfileOwnedBundlesalready syncs the fullBundlemetadata (embedded inside eachBundleKey) into local DB and the bundle detail page can read its header straight from cache. The game grid is driven by paginatedFetchBundleGames. 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:ownedscope.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-gamewith body{ game_id }→{ download_key: <DownloadKey> }. Idempotently materializes a realDownloadKeyfor a game owned via a bundle purchase. Safe to call multiple times.go-itchio changes
New types:
BundleBundleGamejoin struct (mirrorsCollectionGame, holdingBundleID,GameID, position, per-game min price, timestamps)BundleKey: mirrorsDownloadKey/OwnedKey. HoldsID,BundleID,PurchaseID,CreatedAt, and an embeddedBundle *Bundlepopulated from the API responseNew client methods:
ListProfileOwnedBundles(ctx) -> []*BundleKey(each withBundlepopulated)GetBundleGames(ctx, GetBundleGamesParams{BundleID, Page}) -> []*BundleGameClaimBundleGame(ctx, ClaimBundleGameParams{BundleID, GameID}) -> *DownloadKey: materializes a realDownloadKeyat 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.goalongside the existing Collection entries:The
BundleKeytable is what answers "which bundles does this profile own" — there is no separateProfileBundlejoin, sinceBundleKeyalready carries thePurchaseIDthat 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 ofbundle_gamesagainstbundle_keysfiltered by the profile's purchasesFreshness targets
In
database/models/freshness.go, add:FetchTargetForProfileOwnedBundles(profileID)FetchTargetForBundleGames(bundleID): uselongTTLlikeFetchTargetForCollectionGamesNew RPC handlers
endpoints/fetch/fetch_profileownedbundles.go(modeled onfetch_profilecollections.go). The persistence step must explicitly null out any inlineBundleGamesslice on eachBundleembedded inside aBundleKeyfrom the API response before saving, so the locally-paginatedbundle_gamestable is not clobbered by partial inline data.endpoints/fetch/fetch_bundlegames.go(modeled onfetch_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()incmd/operate/game_utils.goAfter the existing
DownloadKeysByGameID()check and before falling back to the bare API key path, callProfileOwnsGameViaBundle(...). On hit, return anAccesswhoseCredentialsindicate bundle ownership without a materializedDownloadKeyIDyet. Suggested credential shape:2. Materialization gate in install flow
Before
client.ListGameUploads()inendpoints/install/install_queue.go(and any other path that needsDownloadKeyID > 0to fetch a download URL), insert:After materialization the game becomes indistinguishable from any other directly-owned game. Subsequent installs/updates use the normal
DownloadKeycode path with no special casing.3.
FetchCommonsextensionendpoints/fetch/fetch_commons.goshould return one new field onFetchCommonsResult:populated from a single
bundle_games JOIN bundle_keysquery 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 aDownloadKeyID. Materialization only happens at install time.Out of scope (separate issue)
getGameStatus,commonsreducer, and library UI for bundle attributionFetchCommonsfield are in placeReferences