From fd2e266e50f30e5c056fea50ee9fb5e4eb93465a Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 2 Oct 2026 14:01:00 -0400 Subject: [PATCH] [econ] some invention stats --- .../0026_invention_num_downloads.sql | 20 +++++++++++++ apps/econ/src/econ.app.ts | 9 +++--- apps/econ/src/test/integration/api.test.ts | 17 +++++++++-- packages/domain/src/inventory-invention-db.ts | 28 ++++++++++++++++++- 4 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 apps/econ/migrations/0026_invention_num_downloads.sql diff --git a/apps/econ/migrations/0026_invention_num_downloads.sql b/apps/econ/migrations/0026_invention_num_downloads.sql new file mode 100644 index 0000000..431d6ce --- /dev/null +++ b/apps/econ/migrations/0026_invention_num_downloads.sql @@ -0,0 +1,20 @@ +-- An invention's `NumDownloads` is the number of players who have it: the rows in +-- `inventory_invention` that name it. `grantInvention` now re-derives it on every grant +-- (packages/domain/src/inventory-invention-db.ts); every purchase before that left it at +-- the 0 a save mints, so this backfills the inventions already bought. +-- +-- The `invention` table is the `api` worker's (same database). `ModifiedAt` is left alone: +-- being bought is not an edit of the invention. + +-- Backs that per-invention count; the primary key leads with account_id. +-- Generated from INVENTORY_INVENTION_SCHEMA_DDL — keep in sync. +CREATE INDEX IF NOT EXISTS idx_inventory_invention_invention + ON inventory_invention (invention_id); + +UPDATE invention +SET data = json_set( + data, + '$.NumDownloads', + (SELECT COUNT(*) FROM inventory_invention WHERE invention_id = invention.id) +) +WHERE id IN (SELECT invention_id FROM inventory_invention); diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 686ead2..76cf000 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -2675,9 +2675,8 @@ interface SettledInventionPurchase { * * Ownership is recorded in `inventory_invention`; the creator is not sold their own * invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather - * than a second row. The invention’s `NumDownloads` counter is deliberately NOT - * bumped: that column lives on the `invention` table the `api` worker owns, and this - * worker only reads it. + * than a second row. The grant also resyncs the invention’s `NumDownloads` — the count + * of players who have it — and the invention returned carries the new number. */ async function settleInventionPurchase( c: Context, @@ -2723,7 +2722,7 @@ async function settleInventionPurchase( // around them, so order them by what a failure costs. A buyer who paid and got the // invention but left the creator unpaid is recoverable; a buyer charged for nothing // is not. - await grantInvention(c.env.DB, id, inventionId) + const downloads = await grantInvention(c.env.DB, id, inventionId) if (price > 0) { // Seed the creator's signup grant BEFORE crediting them: `creditCurrency` upserts @@ -2760,7 +2759,7 @@ async function settleInventionPurchase( if (price > 0) { await pushBalancePurchase(c, id, CurrencyType.RecCenterTokens, -price, balance) } - return { invention, balance } + return { invention: { ...invention, NumDownloads: downloads }, balance } } // strict: false so trailing-slash routes (e.g. `/gifts/consume/`, which the client diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 8948f5c..389cb24 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -5054,7 +5054,7 @@ describe('econ endpoints', () => { } InventionResponse: { Status: number - Invention: { InventionId: number; Name: string } + Invention: { InventionId: number; Name: string; NumDownloads: number } InventionVersion: { InventionId: number; VersionNumber: number } } } @@ -5070,9 +5070,22 @@ describe('econ endpoints', () => { expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8]) - // Owning an invention is boolean: buying it again is a conflict, not a second row. + // `NumDownloads` is the number of players who have it, re-derived on the grant: + // the response carries it and so does the stored record. + const storedDownloads = async (): Promise => + ( + await env.DB.prepare( + "SELECT json_extract(data, '$.NumDownloads') AS n FROM invention WHERE id = 8" + ).first<{ n: number }>() + )?.n ?? -1 + expect(body.InventionResponse.Invention.NumDownloads).toBe(1) + expect(await storedDownloads()).toBe(1) + + // Owning an invention is boolean: buying it again is a conflict, not a second row — + // and not a second download. expect((await buyInvention('50', 8)).status).toBe(409) expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8]) + expect(await storedDownloads()).toBe(1) }) test('GET /api/storefronts/v2/buyInvention pays the creator the buyer’s tokens', async () => { diff --git a/packages/domain/src/inventory-invention-db.ts b/packages/domain/src/inventory-invention-db.ts index 443af05..43e084c 100644 --- a/packages/domain/src/inventory-invention-db.ts +++ b/packages/domain/src/inventory-invention-db.ts @@ -9,6 +9,10 @@ * creator is not listed here either — they own their invention through its * `CreatorPlayerId`, and the buy path refuses to sell an invention to its own creator. * + * The one thing a grant does write on the invention record is its `NumDownloads`: the + * number of rows here that name it, re-derived on every grant (see `grantInvention`), the + * way `CheerCount` is re-derived from `invention_interaction` on every cheer. + * * The `econ` worker owns the schema/migration (apps/econ/migrations/ * 0008_inventory_invention.sql) and is the only writer; `api` only reads, to fold bought * inventions into `GET /api/inventions/v2/mine`. Both import these helpers so the table @@ -23,24 +27,46 @@ export const INVENTORY_INVENTION_SCHEMA_DDL: string[] = [ acquired_at TEXT NOT NULL, PRIMARY KEY (account_id, invention_id) )`, + // Backs the per-invention count `grantInvention` re-derives (migrations/0026). + `CREATE INDEX IF NOT EXISTS idx_inventory_invention_invention + ON inventory_invention (invention_id)`, ] /** * Grant an invention to a player. INSERT OR IGNORE on the (account, invention) primary * key: owning an invention is boolean, so a second grant keeps the original * `acquired_at` rather than back-dating the purchase to now. + * + * Then resyncs the invention's denormalized `NumDownloads` — the number of players who + * have it — and returns it. The count is DERIVED from this table rather than incremented, + * so a repeated grant can't inflate it and a drifted number heals on the next one. It is + * a `json_set` on the one key, like the cheer count, so it doesn't touch `ModifiedAt` or + * race a full-record rewrite into losing anything but this number. */ export async function grantInvention( db: D1Database, accountId: number, inventionId: number -): Promise { +): Promise { await db .prepare( 'INSERT OR IGNORE INTO inventory_invention (account_id, invention_id, acquired_at) VALUES (?1, ?2, ?3)' ) .bind(accountId, inventionId, new Date().toISOString()) .run() + + const row = await db + .prepare('SELECT COUNT(*) AS n FROM inventory_invention WHERE invention_id = ?1') + .bind(inventionId) + .first<{ n: number }>() + const downloads = row?.n ?? 0 + await db + .prepare( + "UPDATE invention SET data = json_set(data, '$.NumDownloads', CAST(?2 AS INTEGER)) WHERE id = ?1" + ) + .bind(inventionId, downloads) + .run() + return downloads } /**