diff --git a/CLI.md b/CLI.md index 9818697..472204f 100644 --- a/CLI.md +++ b/CLI.md @@ -51,6 +51,31 @@ bun runx admin grant-developer --account 1 --revoke bun runx admin grant-moderator --username alice --remote ``` +### `reload-plus` — credit every Plus subscriber's tokens + +Also exposed directly as `just reload-plus`. + +```sh +just reload-plus 1000 --dry-run # list subscribers and their balances, change nothing +just reload-plus 1000 --remote # +1000 RecCenterTokens to each subscriber, production +``` + +Adds `` RecCenterTokens (currency type 2) to the `balance` row of every account +whose `hasPlus` flag is set — the flag `grant-plus` and the website's Discord claim write. +Subscribers are found through the indexed `has_plus` column on `account` (auth migration +0009), so the reload is one statement however many accounts there are. Nothing schedules +it: run it when the subscription's tokens are due, and note that running it twice credits +twice. + +A subscriber who has never loaded the game has no balance row yet. They get one holding +their signup grant **plus** the reload, so the grant is not lost — the grant amount is +`RECFLARE_STARTING_TOKENS` from the environment or `.env`, falling back to econ's default +of 10000. Prints every credited account with its resulting balance. + +This command targets the `balance` table, so it runs under `apps/econ`'s wrangler config; +with `--local` that is econ's dev D1 state, which needs both `balance` and a migrated +`account` table in it. + ### `lookup` — print an account ```sh @@ -65,7 +90,7 @@ the account has a password, the developer role, and the moderator role. ### Selecting an account -Every command targets exactly one account, by **either**: +Every command except `reload-plus` targets exactly one account, by **either**: - `--account ` — numeric account id - `--username ` — username (case-insensitive) diff --git a/Justfile b/Justfile index 8488ba9..a7c84b8 100644 --- a/Justfile +++ b/Justfile @@ -109,10 +109,18 @@ update *args: runx *args: bun runx "$@" -# Admin account tools (set-password, clear-password, grant-developer, lookup). +# Admin account tools (set-password, clear-password, grant-developer, grant-plus, lookup). # Run `just admin --help` for usage and examples. See CLI.md. [group('4. utility')] [positional-arguments] [no-cd] admin *args: bun runx admin "$@" + +# Credit every Rec Room Plus subscriber's RecCenterTokens balance by . Defaults to +# --local; pass --remote for production, --dry-run to only list who'd be credited. See CLI.md. +[group('4. utility')] +[positional-arguments] +[no-cd] +reload-plus *args: + bun runx admin reload-plus "$@" diff --git a/apps/auth/migrations/0009_account_has_plus.sql b/apps/auth/migrations/0009_account_has_plus.sql new file mode 100644 index 0000000..f81471b --- /dev/null +++ b/apps/auth/migrations/0009_account_has_plus.sql @@ -0,0 +1,15 @@ +-- Expose the account's Rec Room Plus flag (`hasPlus` in the JSON blob) as an indexed +-- generated column, so "every Plus member" is an index walk rather than a scan of every +-- account's JSON. The reader is the operator's periodic token reload +-- (`just reload-plus`, packages/tools/src/cmd/admin.cmd.ts), which credits the +-- `balance` row of each Plus member in one statement. +-- +-- The index is PARTIAL: it holds only the rows where the flag is set, so it is as small +-- as the subscriber list and never grows with the account table. A query must say +-- exactly `has_plus = 1` to use it. `json_extract` yields 1 for JSON true, 0 for false +-- and NULL when the key is absent, so neither a revoked nor a never-granted account is +-- in it. Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts (and its mirror +-- in apps/econ/src/avatar-db.ts). + +ALTER TABLE account ADD COLUMN has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL; +CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1; diff --git a/apps/econ/src/avatar-db.ts b/apps/econ/src/avatar-db.ts index d672fe6..d884e21 100644 --- a/apps/econ/src/avatar-db.ts +++ b/apps/econ/src/avatar-db.ts @@ -15,9 +15,11 @@ export const SCHEMA_DDL: string[] = [ data TEXT NOT NULL, avatar TEXT, account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL, - username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL + username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL, + has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL )`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON account (account_id)`, + `CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1`, ] /** The stored avatar payload — opaque JSON the client sets and reads back. */ diff --git a/apps/econ/src/balance-db.ts b/apps/econ/src/balance-db.ts index 3cf2fc9..bcd8536 100644 --- a/apps/econ/src/balance-db.ts +++ b/apps/econ/src/balance-db.ts @@ -1,4 +1,11 @@ import { BalancePlatform } from '../../notify/src/notification-payloads' +import { CurrencyType } from './currency' + +// The currency vocabulary and the Plus reload SQL live in the import-free `currency.ts` so +// the Node CLI in @repo/tools can share them; re-exported here so this stays the module +// everything else reads balances from. +export { CurrencyType, DEFAULT_STARTING_TOKENS, PLUS_MEMBERS_SQL, plusReloadSql } from './currency' +export type { CurrencyTypeValue } from './currency' /** * Currency balances on the shared `recflare` D1 database. @@ -14,50 +21,6 @@ import { BalancePlatform } from '../../notify/src/notification-payloads' * it doesn't clash with the auth/rooms migration histories on the same database. */ -/** - * The currencies the client knows about (its `CurrencyType` enum, obfuscated - * `GKPEKOLBBJL` — which lists every member below except `RoomInventoryItem`). The client sends - * these ints in the balance/storefront paths — `/api/storefronts/v4/balance/2` is - * RecCenterTokens — so the values are fixed by the client, not by us. - * - * What each one is: - * - `Invalid` (0): the enum's zero value. Never a real balance; a request for it is - * a client bug or a probe, and `isSpendable` rejects it. - * - `LaserTagTickets` (1): earned in the Laser Tag activity, spent in its own store. - * - `RecCenterTokens` (2): THE general-purpose currency — what players mean by - * "tokens", earned everywhere and spent in the avatar/gift-drop storefronts. This - * is the only one the client fetches on load, and the only one we grant at signup. - * - `LostSkullsGold` (100) / `DraculaSilver` (101): per-activity currencies for the - * Isle of Lost Skulls and Rise of Jumbo quests. Earned and spent inside those - * activities only. - * - `RecRoyaleSeason1` (200): a season currency for Rec Royale; legacy, no live faucet. - * - `RoomCurrency` (300) / `RoomInventoryItem` (301): NOT global balances. These are - * scoped to a specific room and served by the `/api/roomcurrencies/*` and - * `/api/roomconsumables/*` endpoints, whose rows are keyed by room as well as by - * account. They must never be stored in this (account, currency) table — a single - * row here couldn't say WHICH room's currency it is, so a player's coins in one - * room would spend in every other. `isSpendable` rejects them for that reason. - * - `ProgressionEvent` (400): an XP/progression counter the client models as a - * currency. Not spendable. - * - `RoomieCredits` (500): the newest member of the client's enum. Nothing here grants or - * spends it yet; it is listed so the enum matches the client's and a value arriving on - * the wire has a name rather than reading as an unknown number. - */ -export const CurrencyType = { - Invalid: 0, - LaserTagTickets: 1, - RecCenterTokens: 2, - LostSkullsGold: 100, - DraculaSilver: 101, - RecRoyaleSeason1: 200, - RoomCurrency: 300, - RoomInventoryItem: 301, - ProgressionEvent: 400, - RoomieCredits: 500, -} as const - -export type CurrencyTypeValue = (typeof CurrencyType)[keyof typeof CurrencyType] - /** * The account-scoped currencies this table stores. Everything else in `CurrencyType` * is either not a balance (Invalid, ProgressionEvent) or is room-scoped and belongs to @@ -74,13 +37,6 @@ const SPENDABLE: readonly number[] = [ /** Whether a currency is an account-scoped balance this table may hold. */ export const isSpendable = (currencyType: number): boolean => SPENDABLE.includes(currencyType) -/** - * The signup grant, in RecCenterTokens, when the `STARTING_TOKENS` var is unset. - * An operator overrides it in wrangler.jsonc `vars`; 0 is a valid setting and means - * players start broke. - */ -export const DEFAULT_STARTING_TOKENS = 10_000 - /** * What a player starts with, granted lazily the first time their balances are touched * (see `ensureStartingBalances`). Currencies absent here start at 0. diff --git a/apps/econ/src/currency.ts b/apps/econ/src/currency.ts new file mode 100644 index 0000000..f19183c --- /dev/null +++ b/apps/econ/src/currency.ts @@ -0,0 +1,110 @@ +/** + * The currency vocabulary — which currencies exist and what a player starts with — and the + * operator's Plus token reload, which is SQL text built from them. + * + * Separate from `balance-db.ts` for the same reason `catalog-load.ts` is separate from + * `catalog-db.ts`: that module types its queries with `D1Database`, a Workers type, and the + * `runx admin reload-plus` CLI (@repo/tools) that runs the reload is a plain Node process + * with no such types. Nothing here imports anything, so both sides can use it; `balance-db.ts` + * re-exports all of it, so the worker keeps importing from there. + */ + +/** + * The currencies the client knows about (its `CurrencyType` enum, obfuscated + * `GKPEKOLBBJL` — which lists every member below except `RoomInventoryItem`). The client sends + * these ints in the balance/storefront paths — `/api/storefronts/v4/balance/2` is + * RecCenterTokens — so the values are fixed by the client, not by us. + * + * What each one is: + * - `Invalid` (0): the enum's zero value. Never a real balance; a request for it is + * a client bug or a probe, and `isSpendable` rejects it. + * - `LaserTagTickets` (1): earned in the Laser Tag activity, spent in its own store. + * - `RecCenterTokens` (2): THE general-purpose currency — what players mean by + * "tokens", earned everywhere and spent in the avatar/gift-drop storefronts. This + * is the only one the client fetches on load, and the only one we grant at signup. + * - `LostSkullsGold` (100) / `DraculaSilver` (101): per-activity currencies for the + * Isle of Lost Skulls and Rise of Jumbo quests. Earned and spent inside those + * activities only. + * - `RecRoyaleSeason1` (200): a season currency for Rec Royale; legacy, no live faucet. + * - `RoomCurrency` (300) / `RoomInventoryItem` (301): NOT global balances. These are + * scoped to a specific room and served by the `/api/roomcurrencies/*` and + * `/api/roomconsumables/*` endpoints, whose rows are keyed by room as well as by + * account. They must never be stored in this (account, currency) table — a single + * row here couldn't say WHICH room's currency it is, so a player's coins in one + * room would spend in every other. `isSpendable` rejects them for that reason. + * - `ProgressionEvent` (400): an XP/progression counter the client models as a + * currency. Not spendable. + * - `RoomieCredits` (500): the newest member of the client's enum. Nothing here grants or + * spends it yet; it is listed so the enum matches the client's and a value arriving on + * the wire has a name rather than reading as an unknown number. + */ +export const CurrencyType = { + Invalid: 0, + LaserTagTickets: 1, + RecCenterTokens: 2, + LostSkullsGold: 100, + DraculaSilver: 101, + RecRoyaleSeason1: 200, + RoomCurrency: 300, + RoomInventoryItem: 301, + ProgressionEvent: 400, + RoomieCredits: 500, +} as const + +export type CurrencyTypeValue = (typeof CurrencyType)[keyof typeof CurrencyType] + +/** + * The signup grant, in RecCenterTokens, when the `STARTING_TOKENS` var is unset. + * An operator overrides it in wrangler.jsonc `vars`; 0 is a valid setting and means + * players start broke. + */ +export const DEFAULT_STARTING_TOKENS = 10_000 + +/** + * The periodic Rec Room Plus token reload: credit `amount` RecCenterTokens to every account + * whose `hasPlus` flag is set, in ONE statement. Run by the operator from the CLI + * (`just reload-plus `), which is why this is SQL text rather than a `D1Database` + * helper — `wrangler d1 execute --command` takes a statement, not bindings, so the two + * numbers are validated here and inlined as integer literals. + * + * Subscribers are found through `account.has_plus`, the generated column with the partial + * index (auth migration 0009); the predicate must stay exactly `has_plus = 1` for the + * index to be used. + * + * A subscriber with NO balance row yet is one who has never touched econ, so their signup + * grant (`ensureStartingBalances`) is still pending. Their row is created here with + * `startingTokens + amount` — the grant and the reload together — because a row holding + * only the reload would make `ensureStartingBalances` skip the grant forever (the grant is + * "row exists → already granted"). Existing rows simply gain `amount`, whatever they hold, + * including a 0 spent down to. Same `startingTokens` rule as everywhere else: it is the + * operator's `STARTING_TOKENS`, passed in, never a module default read here. + * + * `RETURNING` reports each credited account and its resulting balance, since wrangler's + * meta carries no reliable change count. + */ +export function plusReloadSql(amount: number, startingTokens: number): string { + if (!Number.isInteger(amount) || amount <= 0) { + throw new Error(`plusReloadSql: amount must be a positive integer, got ${amount}`) + } + if (!Number.isInteger(startingTokens) || startingTokens < 0) { + throw new Error( + `plusReloadSql: startingTokens must be a non-negative integer, got ${startingTokens}` + ) + } + return `INSERT INTO balance (account_id, currency_type, amount) + SELECT account_id, ${CurrencyType.RecCenterTokens}, ${startingTokens + amount} + FROM account WHERE has_plus = 1 + ON CONFLICT (account_id, currency_type) DO UPDATE SET amount = amount + ${amount} + RETURNING account_id, amount` +} + +/** + * Every Plus subscriber with their current RecCenterTokens balance — NULL for one whose + * signup grant is still pending (see `plusReloadSql`). The read half of the reload: what + * the CLI shows before it credits, and all a `--dry-run` does. + */ +export const PLUS_MEMBERS_SQL = `SELECT a.account_id, json_extract(a.data, '$.username') AS username, b.amount + FROM account a + LEFT JOIN balance b ON b.account_id = a.account_id AND b.currency_type = ${CurrencyType.RecCenterTokens} + WHERE a.has_plus = 1 + ORDER BY a.account_id` diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 7e70c5e..a4e801b 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -44,6 +44,8 @@ import { CurrencyType, DEFAULT_STARTING_TOKENS, getBalance, + PLUS_MEMBERS_SQL, + plusReloadSql, spendCurrency, } from '../../balance-db' import { @@ -5656,3 +5658,73 @@ describe('catalog', () => { expect(await countCatalog(env.DB)).toEqual({ avatar_item: 1 }) }) }) + +/** + * The operator's Plus token reload (`just reload-plus`), which is SQL text rather than a + * helper because it is run through `wrangler d1 execute`. Exercised here against a real D1 + * so the upsert's two halves — "existing row gains the amount" and "missing row is created + * with the signup grant folded in" — are pinned, and so the `has_plus` generated column the + * statement depends on is proven to exist in the mirrored schema. + */ +describe('plus token reload', () => { + const seed = async (accountId: number, hasPlus: boolean | undefined) => { + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId, + username: `Plus${accountId}`, + ...(hasPlus === undefined ? {} : { hasPlus }), + }) + ) + .run() + } + + test('credits every hasPlus account once, folding the signup grant into new rows', async () => { + await seed(8001, true) // subscriber with an existing balance + await seed(8002, true) // subscriber who has never touched econ: no balance row + await seed(8003, false) // explicitly not a subscriber + await seed(8004, undefined) // flag never set + await env.DB.prepare( + 'INSERT OR REPLACE INTO balance (account_id, currency_type, amount) VALUES (8001, 2, 250)' + ).run() + await env.DB.prepare( + 'INSERT OR REPLACE INTO balance (account_id, currency_type, amount) VALUES (8003, 2, 250)' + ).run() + + // The read half: subscribers only, with the pending grant shown as a NULL balance. + const members = await env.DB.prepare(PLUS_MEMBERS_SQL).all<{ + account_id: number + username: string + amount: number | null + }>() + expect(members.results.filter((r) => r.account_id >= 8001 && r.account_id <= 8004)).toEqual([ + { account_id: 8001, username: 'Plus8001', amount: 250 }, + { account_id: 8002, username: 'Plus8002', amount: null }, + ]) + + const res = await env.DB.prepare(plusReloadSql(1000, 500)).all<{ + account_id: number + amount: number + }>() + const credited = res.results.filter((r) => r.account_id >= 8001 && r.account_id <= 8004) + expect(credited).toEqual( + expect.arrayContaining([ + { account_id: 8001, amount: 1250 }, + { account_id: 8002, amount: 1500 }, + ]) + ) + expect(credited).toHaveLength(2) + + // The non-subscribers were left exactly as they were, and the fresh row is now a real + // grant: `ensureStartingBalances` sees it and does not grant again. + expect(await getBalance(env.DB, 8003, CurrencyType.RecCenterTokens, 500)).toBe(250) + expect(await getBalance(env.DB, 8004, CurrencyType.RecCenterTokens, 500)).toBe(500) + expect(await getBalance(env.DB, 8002, CurrencyType.RecCenterTokens, 500)).toBe(1500) + }) + + test('rejects a non-positive amount or a negative grant before touching the database', () => { + expect(() => plusReloadSql(0, 500)).toThrow('positive integer') + expect(() => plusReloadSql(1.5, 500)).toThrow('positive integer') + expect(() => plusReloadSql(100, -1)).toThrow('non-negative integer') + }) +}) diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index 5923e87..0de8141 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -13,18 +13,25 @@ /** * Schema DDL — the head schema, i.e. what the table looks like after every migration - * (0001_accounts + 0002_avatar, sans seed INSERTs; 0004 added a `platform_id` generated - * column and 0008 dropped it again, so it appears here in neither form). + * (0001_accounts + 0002_avatar + 0009_account_has_plus, sans seed INSERTs; 0004 added a + * `platform_id` generated column and 0008 dropped it again, so it appears here in neither + * form). + * + * `has_plus` mirrors the blob's `hasPlus` so the operator's Plus token reload can find every + * subscriber through its partial index instead of scanning every account's JSON. It is 1 for + * JSON true, 0 for false and NULL when the key is absent; the index holds only the 1s. */ export const SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS account ( data TEXT NOT NULL, avatar TEXT, account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL, - username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL + username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL, + has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL )`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON account (account_id)`, `CREATE INDEX IF NOT EXISTS idx_accounts_username_lower ON account (username_lower)`, + `CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1`, ] /** Client-facing account shape (camelCase, exactly as the client's AccountDTO). */ diff --git a/packages/tools/src/cmd/admin.cmd.ts b/packages/tools/src/cmd/admin.cmd.ts index 81d7aa1..52c8dbe 100644 --- a/packages/tools/src/cmd/admin.cmd.ts +++ b/packages/tools/src/cmd/admin.cmd.ts @@ -2,7 +2,12 @@ import * as readline from 'node:readline' import { Command } from '@commander-js/extra-typings' import Table from 'cli-table3' -import { execSql, resolveRemote, sqlStr, target } from '../d1' +import { + DEFAULT_STARTING_TOKENS, + PLUS_MEMBERS_SQL, + plusReloadSql, +} from '../../../../apps/econ/src/currency' +import { execSql, readRootEnv, resolveRemote, sqlStr, target } from '../d1' import { hashPassword } from '../password' import type { D1ExecResult } from '../d1' @@ -19,6 +24,7 @@ import type { D1ExecResult } from '../d1' * runx admin lookup --username alice [--remote] * runx admin grant-developer --account 1 [--revoke] [--remote] * runx admin grant-plus --username alice [--revoke] [--remote] + * runx admin reload-plus [--dry-run] [--remote] */ /** @@ -173,6 +179,83 @@ const grantModerator = grantRoleCommand('grant-moderator', 'isModerator', 'moder */ const grantPlus = grantRoleCommand('grant-plus', 'hasPlus', 'Rec Room Plus', 'subscription') +/** + * The Rec Room Plus token reload: credit every subscriber's RecCenterTokens balance by + * `amount`, in one statement. Nothing schedules this — an operator runs it when the + * subscription's tokens are due (`just reload-plus 1000 --remote`), so running it twice + * credits twice; `--dry-run` shows who would be credited and what they hold, and changes + * nothing. + * + * Who is a subscriber is `account.hasPlus`, found through the `has_plus` generated column + * (auth migration 0009) — the same flag `grant-plus` and the website's Discord claim set, + * read straight off the row, so it does not lag a login the way the token's `rn.plus` + * claim does. A subscriber who has never touched econ has no balance row yet; they get one + * holding their signup grant plus the reload, so the grant isn't lost — see + * `plusReloadSql`. The grant amount is `RECFLARE_STARTING_TOKENS` from the environment + * or .env, the value a deploy ships to econ, falling back to econ's built-in default. + * + * Runs under econ's wrangler config because `balance` is econ's table; locally that + * means econ's dev D1 state, which has to hold both `balance` AND a migrated `account`. + */ +/** A row of `PLUS_MEMBERS_SQL` (and, sans `username`, of the reload's `RETURNING`). */ +interface PlusMemberRow { + account_id: number + username?: string | null + /** NULL when the account has no balance row yet, i.e. its signup grant is still pending. */ + amount: number | null +} + +const reloadPlus = new Command('reload-plus') + .description('Credit every Rec Room Plus subscriber with RecCenterTokens') + .argument('', 'Tokens to add to each subscriber (a positive integer)') + .option('--dry-run', 'List the subscribers and their balances without crediting', false) + .option('--local', 'Target the local dev database (the default).', false) + .option('--remote', 'Target the deployed database instead of the local dev database.', false) + .action(async (amountArg, opts) => { + if (!/^\d+$/.test(amountArg) || Number(amountArg) < 1) { + throw new Error(' must be a positive integer') + } + const amount = Number(amountArg) + const remote = resolveRemote(opts) + const startingRaw = await readRootEnv('RECFLARE_STARTING_TOKENS') + const startingTokens = startingRaw == null ? DEFAULT_STARTING_TOKENS : Number(startingRaw) + if (!Number.isInteger(startingTokens) || startingTokens < 0) { + throw new Error(`RECFLARE_STARTING_TOKENS is not a non-negative integer: ${startingRaw}`) + } + + console.log(`Plus subscribers on ${target(remote)}`) + const before = (await execSql(PLUS_MEMBERS_SQL, remote, 'econ')).results + if (before.length === 0) { + console.log(chalk.yellow('no accounts have hasPlus set — nothing to reload')) + return + } + const table = new Table({ head: ['account', 'username', 'tokens'] }) + for (const r of before) { + table.push([ + String(r.account_id), + r.username ?? '', + r.amount == null ? chalk.dim(`(none: grant ${startingTokens} pending)`) : String(r.amount), + ]) + } + console.log(table.toString()) + + if (opts.dryRun) { + console.log( + chalk.yellow(`dry run: would add ${amount} tokens to ${before.length} account(s)`) + ) + return + } + + console.log(`Adding ${amount} tokens to ${before.length} account(s)`) + const credited = ( + await execSql(plusReloadSql(amount, startingTokens), remote, 'econ') + ).results + const after = new Table({ head: ['account', 'tokens'] }) + for (const r of credited) after.push([String(r.account_id), String(r.amount)]) + console.log(after.toString()) + console.log(chalk.green(`✓ ${amount} tokens added to ${credited.length} account(s)`)) + }) + const lookup = new Command('lookup') .description('Print an account by id or username') .option('--account ', 'Account id to look up') @@ -224,6 +307,7 @@ export const adminCmd = new Command('admin') .addCommand(grantDeveloper) .addCommand(grantModerator) .addCommand(grantPlus) + .addCommand(reloadPlus) .addCommand(lookup) .addHelpText( 'after', @@ -239,5 +323,7 @@ Examples: $ runx admin grant-developer --account 1 [--revoke] $ runx admin grant-moderator --username alice --remote $ runx admin grant-plus --username alice # Rec Room Plus; takes effect next login + $ runx admin reload-plus 1000 --remote # +1000 tokens to every Plus subscriber + $ runx admin reload-plus 1000 --dry-run # just list them $ runx admin lookup --username alice --remote` ) diff --git a/packages/tools/src/d1.ts b/packages/tools/src/d1.ts index 7899010..3a4febf 100644 --- a/packages/tools/src/d1.ts +++ b/packages/tools/src/d1.ts @@ -18,8 +18,8 @@ import { getRepoRoot } from './path' /** The one shared database every D1-backed worker binds. */ export const DB_NAME = 'recflare' -export interface D1ExecResult { - results: Array> +export interface D1ExecResult> { + results: Row[] success: boolean meta: { changes?: number; rows_read?: number } } @@ -37,15 +37,27 @@ export function resolveRemote(opts: { local?: boolean; remote?: boolean }): bool return opts.remote === true } -/** The deployed D1's real id, from the environment or the gitignored root .env. */ -export async function getRemoteD1Id(): Promise { - if (process.env.RECFLARE_D1) return process.env.RECFLARE_D1 +/** + * One `RECFLARE_*` setting, from the process environment first and the gitignored root + * `.env` second — the same precedence `recflare_load_env` in src/sh/env.sh gives the deploy + * and dev scripts, so a CLI reads the value a deploy would ship. Undefined when set in + * neither place (a commented-out line in .env is "not set"). + */ +export async function readRootEnv(key: string): Promise { + if (process.env[key]) return process.env[key] const envPath = path.join(getRepoRoot(), '.env') if (await fs.pathExists(envPath)) { const content = await fs.readFile(envPath, 'utf8') - const m = content.match(/^\s*RECFLARE_D1\s*=\s*(.+?)\s*$/m) + const m = content.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`, 'm')) if (m) return m[1].replace(/^["']|["']$/g, '') } + return undefined +} + +/** The deployed D1's real id, from the environment or the gitignored root .env. */ +export async function getRemoteD1Id(): Promise { + const id = await readRootEnv('RECFLARE_D1') + if (id) return id throw new Error('RECFLARE_D1 is not set — add the recflare D1 id to .env (see .env.example)') } @@ -95,16 +107,16 @@ async function runD1(worker: string, extraArgs: string[], remote: boolean): Prom * only a duration, with no reliable `changes` count, so a statement that needs to know what it * matched has to say `RETURNING`. */ -export async function execSql( +export async function execSql>( sql: string, remote: boolean, worker = 'auth' -): Promise { +): Promise> { const stdout = await runD1(worker, ['--command', sql, '--json'], remote) // wrangler --json prints a one-element array of results to stdout. const start = stdout.indexOf('[') if (start === -1) throw new Error(`unexpected d1 execute output:\n${stdout}`) - const parsed = JSON.parse(stdout.slice(start)) as D1ExecResult[] + const parsed = JSON.parse(stdout.slice(start)) as Array> const first = parsed[0] if (!first) throw new Error(`empty d1 execute result:\n${stdout}`) return first