[econ] catalog fix

This commit is contained in:
Devin Zuczek
2026-10-19 11:05:04 -04:00
parent b1628f28e2
commit be1b275a0a
2 changed files with 25 additions and 31 deletions
+8 -9
View File
@@ -72,9 +72,9 @@ export {
*
* `catalog_id` is the small NUMERIC handle the site uses where a key would be unwieldy. For a
* row the store lists it is the listing's own `PurchasableItemId` — the number `sf3-2025.json`
* sells it under — and for a skin the store never listed it is handed out by the loader from
* `CATALOG_ID_BASE`, well above any real one. It is unique where set, and the migration that
* adds it is 0016, since 0015 was already applied.
* sells it under, including the skins we added to the store (numbered there from
* `UNLISTED_SKIN_ID_BASE`, 1 000 000). A row the file does not list has none and is NULL. It is
* unique where set, and the migration that adds it is 0016, since 0015 was already applied.
*
* Nullability is load-bearing rather than incidental: `tooltip` is genuinely NULL on some rows
* of BOTH kinds and `""` on others, and the client's DTOs serve the distinction through, so the
@@ -120,10 +120,9 @@ export interface CatalogRow {
* the store's `PurchasableItemId`, so the number resolves a row here and a listing in
* `sf3-2025.json` alike.
*
* Still not an identity: an unlisted skin's number is a load-order surrogate, and a store
* could renumber. Never store it, never treat it as what an item IS — `item_key` is that,
* and it is what the inventory holds. NULL only between a row existing and a load numbering
* it.
* Still not an identity: a store could renumber. Never store it, never treat it as what an
* item IS — `item_key` is that. NULL for a row `sf3-2025.json` does not list (a merge keeps
* such rows); every reader filters on `catalog_id IS NOT NULL`.
*/
catalog_id: number | null
kind: string
@@ -259,8 +258,8 @@ export async function getCatalogItem(db: D1Database, itemKey: string): Promise<C
/**
* One catalog row by its numeric handle — a store `PurchasableItemId` for a listed item.
*
* Fine for a request that looked the number up moments ago; never for anything stored, since
* an unlisted skin's number is reassigned by every load.
* The number the client looks items up by, since it is the one sf3-2025.json serves. Not for
* anything stored: `item_key` is the identity, and a fresher store dump could renumber.
*/
export async function getCatalogItemById(
db: D1Database,
+17 -22
View File
@@ -204,17 +204,20 @@ async function verifyLoad(
)
}
// Every row must have come out with a numeric handle. A NULL here is a load that stopped
// between writing rows and numbering them, which nothing else would notice until a lookup by
// number quietly returned nothing.
const unnumbered = await execSql(
'SELECT COUNT(*) AS n FROM catalog WHERE catalog_id IS NULL',
// Every row this load wrote must carry its `PurchasableItemId`. Ids are cleared before the
// inserts and only the inserts set them, so exactly the loaded rows are numbered — fewer is a
// load cut short, which nothing else would notice until a lookup by id quietly found nothing.
// Rows the file does not list stay NULL by design and are not counted.
const numbered = await execSql(
'SELECT COUNT(*) AS n FROM catalog WHERE catalog_id IS NOT NULL',
remote,
OWNER
)
const missing = Number(unnumbered.results[0]?.n ?? 0)
if (missing > 0) {
throw new Error(`load did not finish: ${missing} row(s) have no catalog_id. Re-run it.`)
const withId = Number(numbered.results[0]?.n ?? 0)
if (withId !== rows.length) {
throw new Error(
`load did not finish: ${withId} row(s) carry a PurchasableItemId, expected ${rows.length}. Re-run it.`
)
}
}
@@ -265,8 +268,7 @@ const load = new Command('load')
// load carries the store's numbers: without this, a row already holding one of them
// (because it was in an earlier load under a different key, or the store renumbered)
// collides and the whole merge fails. Nulling first also means a row left un-numbered
// afterwards is visibly a row the store no longer lists, which the tail statement
// below then numbers above everything this load assigned.
// afterwards is visibly a row the store no longer lists.
statements.push('UPDATE catalog SET catalog_id = NULL;')
}
for (let start = 0; start < rows.length; start += CHUNK) {
@@ -279,18 +281,11 @@ const load = new Command('load')
`\nON CONFLICT(item_key) DO UPDATE SET ${CONFLICT_UPDATE};`
)
}
if (!opts.replace) {
// Anything still un-numbered is a row the store no longer lists — a merge keeps those,
// so they need handles too. They go ABOVE the highest id this load assigned, so they can
// never collide with it, ordered by rowid for determinism. Usually this matches nothing
// at all, and it is a single statement either way.
const highest = Math.max(0, ...rows.map((r) => r.id))
statements.push(
`UPDATE catalog SET catalog_id = ${highest} +\n` +
`\t(SELECT COUNT(*) FROM catalog c WHERE c.catalog_id IS NULL AND c.rowid <= catalog.rowid)\n` +
`WHERE catalog_id IS NULL;`
)
}
// No numbering step after the inserts. A row's `catalog_id` is its listing's
// `PurchasableItemId` in sf3-2025.json and nothing else; a row a merge keeps that the file
// no longer lists has no such number, so it stays NULL (every reader filters on
// `catalog_id IS NOT NULL`). Inventing one would hand the client an id the store it is
// served does not contain.
const sql = statements.join('\n')
if (opts.dryRun) {