[econ] fix some storefronts

This commit is contained in:
Devin Zuczek
2026-10-19 12:27:05 -04:00
parent be1b275a0a
commit d918936795
11 changed files with 305 additions and 25 deletions
@@ -0,0 +1,15 @@
-- The served store listing behind a catalog row, verbatim as JSON: the `StoreItems[]` entry
-- of `static/storefronts/sf3-2025.json` whose `PurchasableItemId` is the row's `catalog_id`.
--
-- `POST /api/storefronts/v1/purchasableitems` answers with whole listings — prices, item set,
-- `AvatarItemInfo` and the rest — which the catalog's own columns do not carry. Keeping the
-- listing on the row lets that lookup be one indexed query instead of fetching and parsing the
-- 4 MB storefront file per request.
--
-- Filled by `runx catalog load`, like every other column. NULL on a row the served store does
-- not list (the same rows whose `catalog_id` is NULL), and until the first load after this
-- migration.
--
-- Its own migration because 0015 and 0016 are already applied.
ALTER TABLE catalog ADD COLUMN listing TEXT;
+40 -4
View File
@@ -1,6 +1,6 @@
import { CatalogKind } from './catalog-load'
import type { CatalogKindValue } from './catalog-load'
import type { CatalogKindValue, StoreListing } from './catalog-load'
/**
* The item CATALOG on the shared `recflare` D1 database — every avatar item, consumable and
@@ -96,7 +96,8 @@ export const CATALOG_SCHEMA_DDL: string[] = [
tag_list TEXT,
created_at TEXT,
prefab_name TEXT,
unlocked_level INTEGER
unlocked_level INTEGER,
listing TEXT
)`,
// The search index. Folded, because a name search is case-insensitive and SQLite's LIKE is
// only case-insensitive for ASCII — which these names are not all of.
@@ -138,6 +139,12 @@ export interface CatalogRow {
created_at: string | null
prefab_name: string | null
unlocked_level: number | null
/**
* The served store listing behind the row — its `sf3-2025.json` `StoreItems[]` entry,
* verbatim, as JSON (migration 0028). NULL where `catalog_id` is: a row the store does not
* list has no listing. Read it through {@link getStoreListings}.
*/
listing: string | null
}
/**
@@ -271,6 +278,35 @@ export async function getCatalogItemById(
.first<CatalogRow>()
}
/**
* Store listings by `PurchasableItemId` (the `catalog_id`), as the served store lists them —
* what `POST /api/storefronts/v1/purchasableitems` answers. In the order asked; an unknown id,
* a repeat, or a row without a listing is skipped rather than left as a hole, so the result may
* be SHORTER than the input and must not be read positionally.
*
* Every kind resolves — avatar items, consumables, skins and custom avatar items alike — and the
* listing is served exactly as the store lists it. Only listings that load as no row at all
* (loot boxes, token bundles, consumable packs; see `ListingKind`) are unknown here.
*/
export async function getStoreListings(db: D1Database, ids: number[]): Promise<StoreListing[]> {
const unique = [...new Set(ids)]
if (unique.length === 0) return []
// One bound JSON array rather than a placeholder per id: D1 caps a statement at 100 bound
// variables, and nothing stops the client asking for more.
const { results } = await db
.prepare(
`SELECT catalog_id, listing FROM catalog
WHERE catalog_id IN (SELECT value FROM json_each(?1)) AND listing IS NOT NULL`
)
.bind(JSON.stringify(unique))
.all<{ catalog_id: number; listing: string }>()
const byId = new Map(results.map((r) => [r.catalog_id, r.listing]))
return unique.flatMap((id) => {
const listing = byId.get(id)
return listing === undefined ? [] : [JSON.parse(listing) as StoreListing]
})
}
/**
* Resolve many keys at once, in the order asked; unknown keys are skipped rather than left as
* holes, so the result may be SHORTER than the input and must not be read positionally. One
@@ -350,8 +386,8 @@ export async function getAvatarItemsByTag(
tag: string
): Promise<CatalogAvatarItem[]> {
const { results } = await db
.prepare('SELECT * FROM catalog WHERE tag_list = ?1 ORDER BY friendly_name')
.bind(tag)
.prepare('SELECT * FROM catalog WHERE kind = ?1 AND tag_list = ?2 ORDER BY friendly_name')
.bind(CatalogKind.AvatarItem, tag)
.all<CatalogRow>()
return results.map(toCatalogAvatarItem)
}
+49 -5
View File
@@ -29,6 +29,13 @@ export const CatalogKind = {
Skin: 'skin',
/** A consumable: food, a potion, a KO icon. Its `item_key` is the `ConsumableItemDesc`. */
Consumable: 'consumable',
/**
* A first-party custom avatar item the store lists. Its `item_key` is the
* `CustomAvatarItemId`. The ITEM lives in `api`'s `custom_avatar_item` table (`just cai-load`)
* and is resolved there; the row here exists for its LISTING, so a `PurchasableItemId` lookup
* answers for every item the store sells. Only the shared columns are filled.
*/
CustomAvatarItem: 'custom_avatar_item',
} as const
export type CatalogKindValue = (typeof CatalogKind)[keyof typeof CatalogKind]
@@ -114,15 +121,13 @@ export interface SkinCapture {
/**
* What a store listing SELLS — the catalog kind it loads as, or why it loads as nothing.
*
* `custom_avatar_item` listings are first-party UGC: they live in the `custom_avatar_item`
* table (`just cai-load`), not here. `query` is a loot box and `currency` a token bundle —
* `query` is a loot box and `currency` a token bundle —
* neither is an item. `unknown` carries no id at all (two expo items the dump prices at
* nothing). `consumable_pack` is assigned by the load rather than by {@link listingKind}: a
* consumable the store sells singly AND as a pack ("Disco Dance Break", "… 3-Pack") is one
* consumable listed twice, and the catalog is of things, not of listings.
*/
export type ListingKind =
CatalogKindValue | 'custom_avatar_item' | 'query' | 'currency' | 'unknown' | 'consumable_pack'
export type ListingKind = CatalogKindValue | 'query' | 'currency' | 'unknown' | 'consumable_pack'
/** Classify one listing by which of its item ids is set. */
export function listingKind(listing: StoreListing): ListingKind {
@@ -131,7 +136,7 @@ export function listingKind(listing: StoreListing): ListingKind {
if (drop.ConsumableItemDesc !== '') return CatalogKind.Consumable
if (drop.EquipmentModificationGuid !== '') return CatalogKind.Skin
if (drop.CustomAvatarItemId !== null && drop.CustomAvatarItemId !== '') {
return 'custom_avatar_item'
return CatalogKind.CustomAvatarItem
}
if (drop.IsQuery) return 'query'
if (drop.Currency > 0) return 'currency'
@@ -270,6 +275,7 @@ export const CATALOG_INSERT_COLUMNS = [
'created_at',
'prefab_name',
'unlocked_level',
'listing',
] as const
/** A value bound into a load's INSERT. `undefined` is a key the capture omitted. */
@@ -332,6 +338,7 @@ function avatarItemRow(listing: StoreListing): CatalogLoadRow {
null,
null,
null,
JSON.stringify(listing),
],
}
}
@@ -359,6 +366,7 @@ function consumableRow(listing: StoreListing): CatalogLoadRow {
null,
null,
null,
JSON.stringify(listing),
],
}
}
@@ -386,6 +394,39 @@ function listedSkinRow(listing: StoreListing): CatalogLoadRow {
null,
drop.EquipmentPrefabName,
null,
JSON.stringify(listing),
],
}
}
/**
* A listed first-party custom avatar item: the shared columns, keyed by its
* `CustomAvatarItemId`. The record itself is `api`'s; this row carries the listing.
*/
function customAvatarItemRow(listing: StoreListing): CatalogLoadRow {
const drop = listing.GiftDrop
const guid = drop.CustomAvatarItemId as string
return {
key: guid,
id: listing.PurchasableItemId,
label: `${drop.FriendlyName} (custom avatar item)`,
values: [
guid,
listing.PurchasableItemId,
CatalogKind.CustomAvatarItem,
drop.FriendlyName,
drop.Tooltip,
drop.Rarity,
ALL_PLATFORMS,
drop.ThumbnailImageName,
null,
null,
null,
drop.TagList,
null,
null,
null,
JSON.stringify(listing),
],
}
}
@@ -445,6 +486,9 @@ export function buildCatalogLoad(listings: StoreListing[]): CatalogLoad {
case CatalogKind.Skin:
push(listedSkinRow(listing))
break
case CatalogKind.CustomAvatarItem:
push(customAvatarItemRow(listing))
break
default:
skipped[kind] = (skipped[kind] ?? 0) + 1
}
+41 -1
View File
@@ -59,7 +59,7 @@ import {
isSpendable,
spendCurrency,
} from './balance-db'
import { getCatalogItem, toCatalogAvatarItem } from './catalog-db'
import { getCatalogItem, getStoreListings, toCatalogAvatarItem } from './catalog-db'
// `LEGACY_CLIENT_BUILD` is shared with the storefront generator rather than restated: it picks
// which store FILE a caller is served here, and is what the generator's help names.
import { CatalogKind, LEGACY_CLIENT_BUILD } from './catalog-load'
@@ -130,6 +130,7 @@ import {
OPTIONAL_AUTHED,
OwnsRoomKeysRequest,
OwnsRoomKeysResponse,
PurchasableItemsRequest,
PurchaseRoomCurrencyEnvelope,
PurchaseRoomCurrencyRequest,
PurchaseRoomKeyWithCurrencyRequest,
@@ -5061,6 +5062,45 @@ const app = new Hono<App>({ strict: false })
}
)
// Store listings by `PurchasableItemId` — `{ PurchasableItemIds: [2594], GiftDropIds: null }`.
// Answers a bare array of the listings exactly as the served 2025 store (`sf3-2025.json`)
// lists them, read from the `catalog` table's `listing` column rather than the 4 MB file
// (a row's `catalog_id` IS its `PurchasableItemId`). Every item kind resolves; unknown ids
// are dropped, so the answer is in request order but not positional. `GiftDropIds` has only
// been seen null and is ignored. No auth: a listing is what the public store shows anyone.
.post(
'/api/storefronts/v1/purchasableitems',
describeRoute({
tags: ['Storefront'],
summary: 'Store listings by PurchasableItemId',
description: [
'Resolves `PurchasableItemIds[]` against the `catalog` table and answers each listing',
'verbatim as the served 2025 store (`sf3-2025.json`) lists it — `GiftDrop`, `Prices`,',
'`SubscriberPrices` and the rest. Avatar items, consumables, skins and custom avatar',
'items all resolve. Unknown ids (and repeats) are dropped, so the response follows',
'request order but is never a positional match. `GiftDropIds` is ignored.',
].join(' '),
requestBody: jsonBody(PurchasableItemsRequest, 'The listings to resolve'),
responses: {
200: json(JsonArray, 'The resolved listings (unknown ids omitted)'),
400: json(ErrorResponse, 'Malformed body'),
},
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (!body || typeof body !== 'object')
return c.json({ error: 'A JSON body is required' }, 400)
const raw = body.PurchasableItemIds
if (raw !== undefined && raw !== null && !Array.isArray(raw)) {
return c.json({ error: 'PurchasableItemIds must be an array' }, 400)
}
const ids = ((raw as unknown[] | null | undefined) ?? []).filter((id): id is number =>
Number.isInteger(id)
)
return c.json(await getStoreListings(c.env.DB, ids))
}
)
// The consumable DEFINITIONS the client knows how to use (`?supportedVersion=2`), as
// opposed to the ones a player owns. Served verbatim from `static/consumables.json`, the
// live game's list; `supportedVersion` is accepted and unused. A row's `ConfigurationData`
+12
View File
@@ -633,6 +633,18 @@ export const ItemPurchaseInfosRequest = z.object({
),
})
/** `POST /api/storefronts/v1/purchasableitems` — store listings by id. */
export const PurchasableItemsRequest = z.object({
PurchasableItemIds: z
.array(z.number().int())
.nullable()
.describe('The listings to resolve, by `PurchasableItemId`'),
GiftDropIds: z
.array(z.number().int())
.nullable()
.describe('Sent by the client (observed null); accepted and ignored'),
})
/** One `Prices[]` entry: what the item costs in one currency, and any sale on top. */
export const ItemPriceDto = z.object({
CurrencyType: z.number().int().describe('2 = RecCenterTokens — what UGC items are priced in'),
+81 -8
View File
@@ -42,6 +42,7 @@ import { NotificationType } from '../../../../notify/src/notification-types'
// SQL, not modules: they are never executed here, only read.
import catalogStructureSql from '../../../migrations/0015_catalog.sql?raw'
import catalogIdSql from '../../../migrations/0016_catalog_id.sql?raw'
import catalogListingSql from '../../../migrations/0028_catalog_listing.sql?raw'
import skinsJson from '../../../static/db/skins.json'
// The 2025 store DUMP, the source `runx storefront build` and `runx catalog load` both read.
import storeDump from '../../../static/db/Watch_EnumValue_3.json'
@@ -3707,6 +3708,57 @@ describe('econ endpoints', () => {
])
})
test('POST /api/storefronts/v1/purchasableitems answers the served listings verbatim', async () => {
// One listing of each kind the catalog holds, loaded exactly as `runx catalog load` would.
const pick = (pred: (l: StoreListing) => boolean) => storeListings.find(pred)!
const wanted = [
pick((l) => l.PurchasableItemId === 2594), // an avatar item
pick((l) => l.GiftDrop.ConsumableItemDesc !== ''),
pick((l) => l.GiftDrop.EquipmentModificationGuid !== ''),
pick((l) => (l.GiftDrop.CustomAvatarItemId ?? '') !== ''),
]
const ids = new Set(wanted.map((l) => l.PurchasableItemId))
const rows = buildCatalogLoad(storeListings).rows.filter((r) => ids.has(r.id))
expect(rows).toHaveLength(wanted.length)
await env.DB.batch(
rows.map((row) =>
env.DB.prepare(
`INSERT OR REPLACE INTO catalog (${CATALOG_INSERT_COLUMNS.join(', ')})
VALUES (${CATALOG_INSERT_COLUMNS.map((_, i) => `?${i + 1}`).join(', ')})`
).bind(...row.values.map((v) => v ?? null))
)
)
// Request order, unknown ids and repeats dropped, `GiftDropIds` ignored. No token needed.
const order = [...wanted].reverse().map((l) => l.PurchasableItemId)
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v1/purchasableitems`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
PurchasableItemIds: [order[0], 12_345_678, ...order, order[1]],
GiftDropIds: null,
}),
})
expect(res.status).toBe(200)
const body = (await res.json()) as StoreListing[]
expect(body).toEqual(order.map((id) => storeListings.find((l) => l.PurchasableItemId === id)))
expect(body.at(-1)?.GiftDrop.FriendlyName).toBe('Hot Dog Suit (Ketchup)')
// Null ids answer an empty list; a non-array is a malformed body.
const none = await exports.default.fetch(`${ORIGIN}/api/storefronts/v1/purchasableitems`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ PurchasableItemIds: null, GiftDropIds: null }),
})
expect(await none.json()).toEqual([])
const bad = await exports.default.fetch(`${ORIGIN}/api/storefronts/v1/purchasableitems`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ PurchasableItemIds: 2594 }),
})
expect(bad.status).toBe(400)
})
test('POST /api/items/purchaseInfos 400s without Ids and 401s without a token', async () => {
const bad = await exports.default.fetch(`${ORIGIN}/api/items/purchaseInfos`, {
method: 'POST',
@@ -7571,6 +7623,7 @@ describe('econ endpoints', () => {
'POST /api/roomkeys/v1/create',
'POST /api/roomkeys/v1/owns/bulk',
'POST /api/storefronts/v1/PurchaseRoomKeyWithCurrency',
'POST /api/storefronts/v1/purchasableitems',
'POST /api/storefronts/v2/buyItem',
'POST /api/storefronts/v3/buyInvention',
'POST /api/timedCurrencyGrants/update',
@@ -7893,7 +7946,7 @@ describe('catalog', () => {
// separate because 0015 was already applied, and an edit there would never re-run — which
// is exactly the drift this test exists to catch. `CATALOG_SCHEMA_DDL` declares the end
// state in one CREATE, so it is compared against the pair.
const migrations = `${catalogStructureSql}\n${catalogIdSql}`
const migrations = `${catalogStructureSql}\n${catalogIdSql}\n${catalogListingSql}`
// Compared on identifiers rather than text, since the two are formatted differently, and
// `catalog_id` arrives via ALTER rather than inside the CREATE.
@@ -7904,6 +7957,7 @@ describe('catalog', () => {
expect(catalogStructureSql).toContain('item_key TEXT PRIMARY KEY')
expect(CATALOG_SCHEMA_DDL[0]).toContain('item_key TEXT PRIMARY KEY')
expect(catalogIdSql).toContain('ALTER TABLE catalog ADD COLUMN catalog_id INTEGER')
expect(catalogListingSql).toContain('ALTER TABLE catalog ADD COLUMN listing TEXT')
for (const index of [
'idx_catalog_name',
'idx_catalog_prefab',
@@ -7968,13 +8022,27 @@ describe('catalog', () => {
)
expect(row.values[CATALOG_INSERT_COLUMNS.indexOf('rarity')]).toBe(both.GiftDrop.Rarity)
// What the store lists that is NOT a catalog item is counted, not loaded: first-party
// custom items (the `custom_avatar_item` table's), loot boxes, token bundles — and the
// PACKS, a consumable listed again under another number with a count. Every listing is
// accounted for: a row, a skipped count, or nothing else.
expect(skipped.custom_avatar_item).toBe(
storeListings.filter((l) => l.GiftDrop.CustomAvatarItemId).length
// First-party custom items load as rows of their own, keyed by guid: the item is
// `api`'s `custom_avatar_item` record, but its LISTING has to resolve by number too.
expect(kinds.filter((k) => k === 'custom_avatar_item')).toHaveLength(
listed((l) => l.GiftDrop.CustomAvatarItemId ?? '')
)
// Every row carries its listing verbatim, so the store's own JSON comes back out.
const listingAt = CATALOG_INSERT_COLUMNS.indexOf('listing')
const byPurchasableId = new Map(storeListings.map((l) => [l.PurchasableItemId, l]))
expect(
rows.every(
(r) =>
r.values[listingAt] === JSON.stringify(byPurchasableId.get(r.id)) &&
r.values[listingAt] !== undefined
)
).toBe(true)
// What the store lists that is NOT a catalog item is counted, not loaded: loot boxes,
// token bundles — and the PACKS, a consumable listed again under another number with a
// count. Every listing is accounted for: a row, a skipped count, or nothing else.
expect(skipped.custom_avatar_item).toBeUndefined()
expect(skipped.query).toBe(storeListings.filter((l) => l.GiftDrop.IsQuery).length)
expect(skipped.consumable_pack).toBeGreaterThan(0)
expect(
@@ -8031,7 +8099,12 @@ describe('catalog', () => {
const drop = byId.get(row.id)?.GiftDrop
expect(drop, String(row.id)).toBeDefined()
expect(
[drop!.AvatarItemDesc, drop!.ConsumableItemDesc, drop!.EquipmentModificationGuid],
[
drop!.AvatarItemDesc,
drop!.ConsumableItemDesc,
drop!.EquipmentModificationGuid,
drop!.CustomAvatarItemId,
],
String(row.id)
).toContain(row.key)
}
+4 -1
View File
@@ -1302,8 +1302,11 @@ async function findGiftableItem(db: D1Database, requested: string): Promise<Gift
getCatalogItem(db, requested),
])
const item = custom !== null && custom.Accessibility !== 0 ? custom : null
const row =
const found =
byKey ?? (/^\d{1,9}$/.test(requested) ? await getCatalogItemById(db, Number(requested)) : null)
// A custom avatar item's catalog row only carries its store listing; the item itself is the
// `custom_avatar_item` record found above, so the row must not make its guid `ambiguous`.
const row = found?.kind === CatalogKind.CustomAvatarItem ? null : found
if (item !== null && row !== null) return { kind: 'ambiguous' }
if (item !== null) return { kind: 'custom_item', item }