From be1b275a0aad551d0d00dd53df56e956f590f327 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 19 Oct 2026 11:05:04 -0400 Subject: [PATCH] [econ] catalog fix --- apps/econ/src/catalog-db.ts | 17 ++++++------ packages/tools/src/cmd/catalog.cmd.ts | 39 ++++++++++++--------------- 2 files changed, 25 insertions(+), 31 deletions(-) diff --git a/apps/econ/src/catalog-db.ts b/apps/econ/src/catalog-db.ts index 60bfab7..5796f65 100644 --- a/apps/econ/src/catalog-db.ts +++ b/apps/econ/src/catalog-db.ts @@ -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 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) {