diff --git a/apps/leaderboard/migrations/0001_leaderboard.sql b/apps/leaderboard/migrations/0001_leaderboard.sql new file mode 100644 index 0000000..fd0542a --- /dev/null +++ b/apps/leaderboard/migrations/0001_leaderboard.sql @@ -0,0 +1,29 @@ +-- A room's leaderboards: each player's value on each of a room's stat channels. Owned by +-- the `leaderboard` worker, which is the only reader and the only writer — +-- `POST /leaderboard/CheckAndSetStat` writes a row and the three reads +-- (`GetRanks`, `GetNearbyScores`, `GetPlayerRank`) rank them. Generated from +-- src/leaderboard-db.ts (SCHEMA_DDL) — keep in sync. +-- +-- One row per (player, room, channel). A row is created the first time a player posts a +-- stat for that channel of the room; a player with no row is simply not on that board, +-- which the reads answer as an empty slice / the unranked sentinel rather than by +-- inserting on a read. +-- +-- `stat_channel` is the client's `StatChannel` — which of the room's tracked stats this +-- is (a room keeps one board per channel) — and `stat_value` is what it posts as +-- `StatValue`. What a channel counts (wins, laps, a time) is the room's business; the +-- server only orders on it, highest first unless the client asks for ascending, ties +-- broken on the lower `player_id` so a rank is stable between two reads. + +CREATE TABLE IF NOT EXISTS leaderboard ( + player_id INTEGER NOT NULL, + room_id INTEGER NOT NULL, + stat_channel INTEGER NOT NULL, + stat_value INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (player_id, room_id, stat_channel) + ); + +-- The reads walk one board (room + channel) ordered by value; the primary key serves the +-- point lookup but not that scan. +CREATE INDEX IF NOT EXISTS leaderboard_board_value + ON leaderboard (room_id, stat_channel, stat_value DESC, player_id); diff --git a/apps/leaderboard/package.json b/apps/leaderboard/package.json index 1978c87..0a23d29 100644 --- a/apps/leaderboard/package.json +++ b/apps/leaderboard/package.json @@ -12,10 +12,13 @@ "deploy": "run-wrangler-deploy", "dev": "run-wrangler-dev", "fix:workers-types": "run-wrangler-types", + "migrate": "run-wrangler-migrate", "test": "run-vitest" }, "dependencies": { + "@repo/domain": "workspace:*", "@repo/hono-helpers": "workspace:*", + "@repo/jwt": "workspace:*", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "hono": "4.12.27", diff --git a/apps/leaderboard/src/context.ts b/apps/leaderboard/src/context.ts index 6b549d3..a3faf59 100644 --- a/apps/leaderboard/src/context.ts +++ b/apps/leaderboard/src/context.ts @@ -8,6 +8,8 @@ export type Env = SharedHonoEnv & { * `auth` verify here. */ JWT_SECRET: SecretsStoreSecret + /** Shared `recflare` D1 database; this worker owns the `leaderboard` table. */ + DB: D1Database } /** Variables can be extended */ diff --git a/apps/leaderboard/src/leaderboard-db.ts b/apps/leaderboard/src/leaderboard-db.ts new file mode 100644 index 0000000..64937a8 --- /dev/null +++ b/apps/leaderboard/src/leaderboard-db.ts @@ -0,0 +1,218 @@ +/** + * A room's leaderboards on the shared `recflare` D1 database: each player's value on each + * of a room's stat channels. + * + * One table, one row per (player, room, channel), created the first time a player posts a + * stat for that channel of the room via `POST /leaderboard/CheckAndSetStat`. A player with + * no row is not on that board: the reads answer that as an empty slice or {@link UNRANKED} + * rather than inserting on a read. + * + * A board is a (room, channel) pair — `stat_channel` is the client's `StatChannel`, which of + * the room's tracked stats this is, and `stat_value` is what it posts as `StatValue`. What a + * channel counts (wins, laps, a time) is the room's business; the server only orders on it. + * + * Ranks are 1-based and total: the board is ordered by `stat_value` (highest first unless + * the client asks for ascending) with ties broken on the lower `player_id`, so two players + * with the same score never share a rank and a rank is stable between two reads. + * + * A board can be read through a FRIENDS filter (the client's `FilterType` 1): the same rows + * restricted to the viewer and the people they are friends with, ranked among themselves — + * so a player who is 40th globally can be 2nd among friends. Friendship is the `api` + * worker's `relationship` table on the same database, read here through a subquery rather + * than an `IN (...)` list so a player with hundreds of friends doesn't hit D1's bind limit. + * + * The `leaderboard` worker owns the schema/migrations (migrations/0001_leaderboard.sql, + * applied under its own `migrations_table` so they don't clash with the other workers' + * migrations that share the database). + */ + +import { RelationshipType } from '@repo/domain' + +/** Schema DDL (mirror of migrations/0001_leaderboard.sql). */ +export const SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS leaderboard ( + player_id INTEGER NOT NULL, + room_id INTEGER NOT NULL, + stat_channel INTEGER NOT NULL, + stat_value INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (player_id, room_id, stat_channel) + )`, + `CREATE INDEX IF NOT EXISTS leaderboard_board_value + ON leaderboard (room_id, stat_channel, stat_value DESC, player_id)`, +] + +/** + * The rank a player who isn't on the board gets. `Rank` is 1-based: a 0 would render as + * first place and a negative one may not render at all. A number far past the end of any + * real board reads as last, which is what an unscored player is, and is recognisable in a + * log or a screenshot as a sentinel rather than a real standing. + */ +export const UNRANKED = 99999 + +/** The score behind {@link UNRANKED}: no stat has ever been stored, and 0 is "no score". */ +export const NO_SCORE = 0 + +/** + * One row of a board as the client renders it — the same three fields `GetPlayerRank` + * answers, so a row and a standing are the one shape. + */ +export interface LeaderboardEntry { + PlayerId: number + Score: number + Rank: number +} + +interface LeaderboardRow { + player_id: number + stat_value: number +} + +/** Which board: one of a room's stat channels, optionally seen through one player's friends. */ +export interface Board { + roomId: number + statChannel: number + /** When set, only this player and their friends are on the board. */ + friendsOf?: number +} + +/** + * The WHERE clause selecting a board's rows, with the room and channel bound as `?1`/`?2` + * and, for a friends board, the viewer as `?3`. Callers number any further parameters from + * {@link Scope.next}. + */ +interface Scope { + where: string + binds: number[] + next: number +} + +function scope(board: Board): Scope { + if (board.friendsOf === undefined) { + return { + where: 'room_id = ?1 AND stat_channel = ?2', + binds: [board.roomId, board.statChannel], + next: 3, + } + } + return { + where: `room_id = ?1 AND stat_channel = ?2 AND (player_id = ?3 OR player_id IN ( + SELECT CASE WHEN requester_id = ?3 THEN target_id ELSE requester_id END + FROM relationship + WHERE relationship_type = ${RelationshipType.Friend} AND (requester_id = ?3 OR target_id = ?3) + ))`, + binds: [board.roomId, board.statChannel, board.friendsOf], + next: 4, + } +} + +/** The ORDER BY for a board. Ties break on the lower player id either way. */ +function order(sortAscending: boolean): string { + return sortAscending ? 'stat_value ASC, player_id ASC' : 'stat_value DESC, player_id ASC' +} + +/** + * A page of a board: ranks `rankStart`..`rankEnd`, both 1-based and inclusive. + * A `rankStart` below 1 is clamped to the top; an empty or inverted range is an empty page. + */ +export async function getRanks( + db: D1Database, + board: Board, + rankStart: number, + rankEnd: number, + sortAscending: boolean +): Promise { + const start = Math.max(1, rankStart) + const limit = rankEnd - start + 1 + if (limit <= 0) return [] + const s = scope(board) + const { results } = await db + .prepare( + `SELECT player_id, stat_value FROM leaderboard WHERE ${s.where} + ORDER BY ${order(sortAscending)} LIMIT ?${s.next} OFFSET ?${s.next + 1}` + ) + .bind(...s.binds, limit, start - 1) + .all() + return results.map((r, i) => ({ PlayerId: r.player_id, Score: r.stat_value, Rank: start + i })) +} + +/** + * One player's standing on a board: their score and 1-based rank, or + * {@link UNRANKED} with {@link NO_SCORE} when they have no row there. + * + * The rank is one more than the count of players placed ahead — a higher score, or the + * same score and a lower id — so it matches the position {@link getRanks} would give. + */ +export async function getPlayerRank( + db: D1Database, + board: Board, + playerId: number, + sortAscending: boolean +): Promise { + const s = scope(board) + const mine = await db + .prepare(`SELECT stat_value FROM leaderboard WHERE ${s.where} AND player_id = ?${s.next}`) + .bind(...s.binds, playerId) + .first<{ stat_value: number }>() + if (!mine) return { PlayerId: playerId, Score: NO_SCORE, Rank: UNRANKED } + const [pid, val] = [s.next, s.next + 1] + const ahead = sortAscending + ? `(stat_value < ?${val} OR (stat_value = ?${val} AND player_id < ?${pid}))` + : `(stat_value > ?${val} OR (stat_value = ?${val} AND player_id < ?${pid}))` + const count = await db + .prepare(`SELECT COUNT(*) AS n FROM leaderboard WHERE ${s.where} AND ${ahead}`) + .bind(...s.binds, playerId, mine.stat_value) + .first<{ n: number }>() + return { PlayerId: playerId, Score: mine.stat_value, Rank: (count?.n ?? 0) + 1 } +} + +/** The most rows either side of a player `GetNearbyScores` will serve, whatever it asks. */ +export const MAX_WINDOW = 10 + +/** + * The rows around one player on a board: `windowSize` entries on either side of their + * rank (at most {@link MAX_WINDOW}), clamped to the board. A player who isn't on the board + * gets the top of it — a full window's worth — so the screen still draws something. + */ +export async function getNearbyScores( + db: D1Database, + board: Board, + playerId: number, + windowSize: number, + sortAscending: boolean +): Promise { + const window = Math.min(Math.max(windowSize, 1), MAX_WINDOW) + const mine = await getPlayerRank(db, board, playerId, sortAscending) + if (mine.Rank === UNRANKED) return getRanks(db, board, 1, window * 2 + 1, sortAscending) + return getRanks(db, board, mine.Rank - window, mine.Rank + window, sortAscending) +} + +/** + * A compare-and-set of one player's value on one board, the write behind + * `POST /leaderboard/CheckAndSetStat`. + * + * `expected` is what the client believes is stored: with a number, the row is written only + * if it still holds that value, which is how a stale client is kept from walking a board + * backwards; with null the client believes nothing is stored, and the value is written + * regardless — a row that exists is overwritten rather than the write being refused, since + * the client's belief about a fresh room is the one it can't have checked. + * + * Returns whether the write landed. + */ +export async function checkAndSetStat( + db: D1Database, + board: Board, + playerId: number, + value: number, + expected: number | null +): Promise { + const result = await db + .prepare( + `INSERT INTO leaderboard (player_id, room_id, stat_channel, stat_value) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT (player_id, room_id, stat_channel) DO UPDATE SET stat_value = excluded.stat_value + WHERE ?5 IS NULL OR stat_value = ?5` + ) + .bind(playerId, board.roomId, board.statChannel, value, expected) + .run() + return (result.meta.changes ?? 0) > 0 +} diff --git a/apps/leaderboard/src/leaderboard.app.ts b/apps/leaderboard/src/leaderboard.app.ts index 8cb55a0..8ac2fdb 100644 --- a/apps/leaderboard/src/leaderboard.app.ts +++ b/apps/leaderboard/src/leaderboard.app.ts @@ -3,6 +3,17 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { useWorkersLogger } from 'workers-tagged-logger' import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' +import { validateAndGetAccountId } from '@repo/jwt' + +import { + checkAndSetStat, + getNearbyScores, + getPlayerRank, + getRanks, + MAX_WINDOW, + NO_SCORE, + UNRANKED, +} from './leaderboard-db' import { CheckAndSetStatBody, @@ -17,26 +28,54 @@ import { } from './openapi' import type { App } from './context' +import type { Board } from './leaderboard-db' /** - * Leaderboard Worker. Nothing scores anything here yet — the routes answer the shape the - * client parses, with no rows, no rank and no stored stats behind them. + * Leaderboard Worker. One board per (room, stat channel), stored in the `leaderboard` table + * (see leaderboard-db.ts): `CheckAndSetStat` writes the caller's value on one, and the three + * reads rank them. */ -/** - * The rank a player who isn't on the board gets. Nothing is scored here, so every caller is - * unranked — but "unranked" has to be said in the client's own vocabulary, and `Rank` is - * 1-based: a 0 would render as first place and a negative one may not render at all. A - * number far past the end of any real board reads as last, which is what an unscored player - * is, and is recognisable in a log or a screenshot as a sentinel rather than a real standing. - */ -const UNRANKED = 99999 +/** A board selector as the client posts it. Every field is optional on the wire — a body + * that names nothing still gets an answer, it's just an empty one. */ +interface BoardBody { + PlayerId?: number + RoomId?: number + StatChannel?: number + FilterType?: number + SortAscending?: boolean + RankStart?: number + RankEnd?: number + WindowSize?: number +} + +/** Read a JSON body leniently: an unreadable one is `{}`, never an error — a board that + * fails to draw is worse than one that draws empty. */ +async function readBody(c: { req: { json(): Promise } }): Promise { + return c.req.json().catch(() => ({}) as T) +} + +const int = (v: unknown, fallback: number) => (Number.isInteger(v) ? (v as number) : fallback) + +/** The client's `FilterType`: who a board counts. */ +const enum FilterType { + Global = 0, + Friends = 1, +} /** - * The score behind {@link UNRANKED}. Zero rather than a second sentinel: no stat has ever - * been stored, and 0 is what "no score" means in the client's own units. + * The board a body names: `RoomId` + `StatChannel`, each 0 when absent, seen through + * `PlayerId`'s friends when `FilterType` is Friends. A friends board with no `PlayerId` + * has nobody to be friends of, so it falls back to the global one rather than to nothing. */ -const NO_SCORE = 0 +function board(body: BoardBody): Board { + const playerId = int(body.PlayerId, 0) + return { + roomId: int(body.RoomId, 0), + statChannel: int(body.StatChannel, 0), + ...(int(body.FilterType, 0) === FilterType.Friends && playerId !== 0 && { friendsOf: playerId }), + } +} const app = new Hono() .use( @@ -77,34 +116,41 @@ const app = new Hono() // a blank board instead of failing. The key must be present — a bare `{}` trips its // parser. // - // Nothing is ranked or stored yet, so the request body is ignored. It IS logged: the - // body's shape hasn't been recovered from the client, and this route is how it gets - // watched. Read it as text — the shape is unknown, so parsing it would only invent one — - // and never fail on it, since an unreadable body must not cost the client its board. + // The body is GetPlayerRank's plus `WindowSize`: the rows `WindowSize` either side of + // the player's rank (capped at MAX_WINDOW whatever the client asks — the client asks for + // 10), or the top of the board when they aren't on it. `FilterType` 1 restricts the + // board to the player and their friends, ranked among themselves. An unreadable body is + // answered with an empty board, never an error. .post( '/leaderboard/GetNearbyScores', describeRoute({ tags: ['Leaderboard'], summary: 'The scores around a player', description: [ - 'What the client shows when it opens a leaderboard ON someone rather than at the top.', + 'What the client shows when it opens a leaderboard ON someone rather than at the top:', + `the rows \`WindowSize\` (at most ${MAX_WINDOW}, the default) either side of \`PlayerId\`’s`, + 'rank on the board `RoomId` + `StatChannel` names, or the top of the board when the', + 'player isn’t on it. `FilterType` 1 (Friends) restricts the board to `PlayerId` and', + 'their friends, ranked among themselves.', '', - 'Nothing is scored or stored on this server yet, so `Rows` is always empty — a complete', - 'answer meaning "this leaderboard has no scores", which the client renders as a blank', - 'board rather than failing. The key is always present; a bare `{}` trips its parser.', - '', - 'The request body is IGNORED, and logged rather than parsed: its shape has not been', - 'recovered from the client, so this route is how it gets watched. An unreadable body is', - 'not an error either — it must not cost the client its board.', + 'An empty `Rows` is a complete answer meaning "this leaderboard has no scores", which', + 'the client renders as a blank board rather than failing. The key is always present; a', + 'bare `{}` trips its parser. An unreadable body is answered with an empty board.', ].join(' '), - requestBody: jsonBody(GetNearbyScoresBody, 'Ignored and logged; shape not yet recovered'), - responses: { 200: json(LeaderboardRows, 'The board, always with no rows') }, + requestBody: jsonBody(GetNearbyScoresBody, 'The player and the board to centre on'), + responses: { 200: json(LeaderboardRows, 'The rows around the player') }, }), async (c) => { - const body = await c.req.text().catch(() => '') + const body = await readBody(c) logger.info('GetNearbyScores', { body }) - const rows: unknown[] = [] + const rows = await getNearbyScores( + c.env.DB, + board(body), + int(body.PlayerId, 0), + int(body.WindowSize, MAX_WINDOW), + body.SortAscending === true + ) return c.json({ Rows: rows }) } ) @@ -116,8 +162,9 @@ const app = new Hono() // // Same answer and same rules as GetNearbyScores: `{ Rows: [...] }`, where an EMPTY // `Rows` is a complete answer meaning "this leaderboard has no scores" and the key must - // be present. Nothing is ranked or stored yet, so the body is ignored — only logged, and - // read as text so an unreadable body can never cost the client its board. + // be present. Ranks are 1-based; a `RankStart` of 0 is read as the top. `FilterType` 1 + // ranks the viewer and their friends among themselves. An unreadable body is answered + // with an empty board, never an error. .post( '/leaderboard/GetRanks', describeRoute({ @@ -129,20 +176,26 @@ const app = new Hono() 'plus `StatChannel`), the viewer (`PlayerId`) and the ordering (`FilterType`,', '`SortAscending`).', '', - 'Answers exactly what `GetNearbyScores` answers, under the same rules: `Rows` is always', - 'empty because nothing is scored or stored here yet, and the key is always present.', - '', - 'The body is IGNORED — it is logged, not parsed — so the fields are documented as the', - 'record of what the client asks for rather than as anything the handler reads.', + 'Answers the rows ranked `RankStart`..`RankEnd` on the board `RoomId` + `StatChannel`', + 'names (1-based; 0 is read as the top), highest value first unless `SortAscending`. An', + 'empty `Rows` means "this leaderboard has no scores"; the key is always present.', + '`FilterType` 1 (Friends) restricts the board to `PlayerId` and their friends, ranked', + 'among themselves.', ].join(' '), requestBody: jsonBody(GetRanksBody, 'The slice and board the client is asking for'), - responses: { 200: json(LeaderboardRows, 'The board, always with no rows') }, + responses: { 200: json(LeaderboardRows, 'The requested slice of the board') }, }), async (c) => { - const body = await c.req.text().catch(() => '') + const body = await readBody(c) logger.info('GetRanks', { body }) - const rows: unknown[] = [] + const rows = await getRanks( + c.env.DB, + board(body), + int(body.RankStart, 1), + int(body.RankEnd, 10), + body.SortAscending === true + ) return c.json({ Rows: rows }) } ) @@ -156,9 +209,9 @@ const app = new Hono() // therefore the one field read out of the body: answering with a different player's id // would be answering a question nobody asked. // - // Nothing is scored here, so every caller is unranked and gets {@link UNRANKED} with a - // zero score. A body that can't be read still gets an answer — a board that fails to draw - // is worse than one that draws the player as unranked — so `PlayerId` falls back to 0. + // A player with no row in the room gets {@link UNRANKED} with a zero score. A body that + // can't be read still gets an answer — a board that fails to draw is worse than one that + // draws the player as unranked — so `PlayerId` falls back to 0. .post( '/leaderboard/GetPlayerRank', describeRoute({ @@ -169,24 +222,28 @@ const app = new Hono() 'the board — the body names the player and the board (`RoomId` + `StatChannel` +', '`FilterType`: Global 0, Friends 1).', '', - 'Nothing is scored or stored on this server yet, so the answer is always the same:', - `\`Rank\` ${UNRANKED}, a sentinel meaning unranked (ranks are 1-based, so a 0 would`, - 'render as first place), and `Score` 0.', + '`Score` is the player’s value on the board `RoomId` + `StatChannel` names and `Rank`', + `their 1-based position on it. A player with no row there answers \`Rank\` ${UNRANKED}, a sentinel meaning`, + 'unranked (ranks are 1-based, so a 0 would render as first place), and `Score` 0.', '', - '`PlayerId` is echoed from the request and is the only field read out of it — the', - 'response carries no board selectors, so the client matches the answer to its own', - 'question. An unreadable body is answered rather than rejected, with a `PlayerId` of 0.', + '`FilterType` 1 (Friends) ranks the player among their friends only.', + '', + '`PlayerId` is echoed from the request — the response carries no board selectors, so', + 'the client matches the answer to its own question. An unreadable body is answered', + 'rather than rejected, with a `PlayerId` of 0.', ].join(' '), requestBody: jsonBody(GetPlayerRankBody, 'The player and the board being asked about'), - responses: { 200: json(PlayerRank, 'The player’s standing — always unranked') }, + responses: { 200: json(PlayerRank, 'The player’s standing') }, }), async (c) => { - const body = await c.req - .json<{ PlayerId?: number }>() - .catch(() => ({}) as { PlayerId?: number }) + const body = await readBody(c) logger.info('GetPlayerRank', { body }) - return c.json({ PlayerId: body.PlayerId ?? 0, Score: NO_SCORE, Rank: UNRANKED }) + const playerId = int(body.PlayerId, 0) + if (playerId === 0) return c.json({ PlayerId: 0, Score: NO_SCORE, Rank: UNRANKED }) + return c.json( + await getPlayerRank(c.env.DB, board(body), playerId, body.SortAscending === true) + ) } ) @@ -196,9 +253,11 @@ const app = new Hono() // how a room's high-score board avoids being walked backwards by a stale client. There is // no `PlayerId`: the stat belongs to whoever is calling. // - // Nothing is stored yet, so the write is accepted and dropped. The answer is a BARE `0` — - // not an envelope, not `{ value: 0 }` — which is what the live service returns and so what - // the client's parser expects. The body is logged, not read. + // The caller comes from the bearer token — no token, 401, since a stat with no owner has + // nowhere to go. The write lands in `leaderboard` as the caller's value on the board + // `RoomId` + `StatChannel` names. The answer is a BARE `0` — not an + // envelope, not `{ value: 0 }` — which is what the live service returns and so what the + // client's parser expects; it is 0 even when the compare failed and nothing was written. .post( '/leaderboard/CheckAndSetStat', describeRoute({ @@ -209,19 +268,45 @@ const app = new Hono() 'wants stored, `CurrentStatValue` what it believes is stored now (null when it believes', 'nothing is). No `PlayerId` — the stat belongs to the caller.', '', - 'Nothing is stored on this server yet, so the write is accepted and dropped. The', - 'response is the BARE number `0`, not an envelope and not a `{ value }` wrapper — what', - 'the live service answers, and what the client’s parser expects.', + 'Stores `StatValue` as the caller’s value on the board `RoomId` + `StatChannel` names', + '(the caller is the Bearer token; 401 without one). With a numeric `CurrentStatValue`', + 'the row is written only if it still holds that value; with null it is written', + 'regardless.', '', - 'The body is IGNORED and logged, which is how these shapes get recovered from a live', - 'client.', + 'The response is the BARE number `0`, not an envelope and not a `{ value }` wrapper —', + 'what the live service answers, and what the client’s parser expects — whether or not', + 'the compare passed.', ].join(' '), requestBody: jsonBody(CheckAndSetStatBody, 'The stat, the room and the value to store'), - responses: { 200: json(CheckAndSetStatResponse, 'Always the bare number 0') }, + responses: { + 200: json(CheckAndSetStatResponse, 'Always the bare number 0'), + 401: { description: 'No valid bearer token' }, + }, }), async (c) => { - const body = await c.req.text().catch(() => '') - logger.info('CheckAndSetStat', { body }) + const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) + if (accountId === null) return c.body(null, 401) + + const body = await readBody<{ + RoomId?: number + StatChannel?: number + StatValue?: number + CurrentStatValue?: number | null + }>(c) + logger.info('CheckAndSetStat', { accountId, body }) + + const target = board(body) + if (target.roomId !== 0 && typeof body.StatValue === 'number') { + const expected = typeof body.CurrentStatValue === 'number' ? body.CurrentStatValue : null + const written = await checkAndSetStat( + c.env.DB, + target, + accountId, + Math.trunc(body.StatValue), + expected + ) + if (!written) logger.info('CheckAndSetStat: stale, not written', { accountId, ...target }) + } return c.json(0) } @@ -242,17 +327,20 @@ app.get( 'Leaderboards for recflare, a private-server reimplementation of the Rec Room', 'backend — the boards a room keeps for the stats it tracks.', '', - 'NOTHING IS SCORED HERE YET, and every route answers accordingly rather than', - 'failing: the two board reads answer `{ "Rows": [] }`, an empty list being a', - 'complete answer meaning "this leaderboard has no scores" (the `Rows` key is always', - 'present — a bare `{}` trips the client’s parser); `GetPlayerRank` answers a rank of', - '99999, the sentinel for unranked, with a score of 0; and `CheckAndSetStat` accepts', - 'a stat write, drops it, and answers the bare number `0`.', + 'One board per (room, stat channel): `CheckAndSetStat` stores the caller’s value on', + 'one, and the reads rank them — highest first unless `SortAscending`, ties broken on', + 'the lower player id, ranks 1-based. `FilterType` 1 reads a board as the viewer and', + 'their friends only (the `api` worker’s `relationship` table), ranked among', + 'themselves.', '', - 'Only `GetPlayerRank` reads anything out of its request body, and only the', - '`PlayerId` it echoes back. Every route logs the body verbatim, which is how these', - 'shapes get recovered from a live client; `GetNearbyScores`’ body is still unknown', - 'for exactly that reason. No route needs a token today.', + 'The two board reads answer `{ "Rows": [ { PlayerId, Score, Rank } ] }`, an empty', + 'list being a complete answer meaning "this leaderboard has no scores" (the `Rows`', + 'key is always present — a bare `{}` trips the client’s parser); `GetPlayerRank`', + 'answers a player with no row a rank of 99999, the sentinel for unranked, with a', + 'score of 0; and `CheckAndSetStat` answers the bare number `0`.', + '', + 'Only `CheckAndSetStat` needs a token — the stat belongs to whoever is calling.', + 'Unreadable bodies are answered (empty board / unranked), never rejected.', ].join('\n'), }, servers: [{ url: 'https://leaderboard.recflare.net', description: 'Production' }], diff --git a/apps/leaderboard/src/openapi.ts b/apps/leaderboard/src/openapi.ts index 820478c..b1dc46b 100644 --- a/apps/leaderboard/src/openapi.ts +++ b/apps/leaderboard/src/openapi.ts @@ -27,8 +27,8 @@ export function json(schema: z.ZodType, description: string) { /** * Convert a zod schema to a plain OpenAPI schema for a request body. `describeRoute`'s * `requestBody` takes an OpenAPI schema (not a `resolver()`). zod's `$schema` key and - * `additionalProperties: false` are dropped — the handlers read nothing out of these - * bodies at all, so a closed object would misreport them as stricter than they are. + * `additionalProperties: false` are dropped — the handlers ignore fields they don't read, + * so a closed object would misreport them as stricter than they are. */ function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject { const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema) @@ -45,18 +45,15 @@ export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.Re /** * Both leaderboard reads answer this and nothing else: `{ Rows: [...] }`. * - * `Rows` is EMPTY on this server — nothing scores anything yet — and an empty list is a - * complete answer meaning "this leaderboard has no scores", which the client renders as a - * blank board rather than failing. The key must be present; a bare `{}` trips its parser. - * - * The row shape is therefore undocumented: no live response has ever carried one, so - * describing a row here would be inventing it. It is typed as an open object rather than - * `unknown` so a viewer shows an object in the array. + * An EMPTY `Rows` is a complete answer meaning "this leaderboard has no scores", which the + * client renders as a blank board rather than failing. The key must be present; a bare `{}` + * trips its parser. Each row is the same `{ PlayerId, Score, Rank }` {@link PlayerRank} + * answers — the shape both reference servers serve. */ export const LeaderboardRows = z.object({ Rows: z - .array(z.looseObject({})) - .describe('The board’s rows. Always empty — nothing is scored or stored yet.'), + .array(z.lazy(() => PlayerRank)) + .describe('The board’s rows, in rank order. Empty when the board has no scores.'), }) /** @@ -64,14 +61,14 @@ export const LeaderboardRows = z.object({ * `{"PlayerId":205,"Score":4200,"Rank":17}`. * * Three fields only: none of the board selectors the request names are echoed back, so the - * client matches the answer to the question by having asked it. Nothing is scored here yet, - * so `Rank` is a constant sentinel and `Score` is zero — see the route for why that pairing - * rather than a rank of 0, which would read as "first place". + * client matches the answer to the question by having asked it. A player with no row gets + * the 99999 sentinel with a zero score — see the route for why that rather than a rank of + * 0, which would read as "first place". */ export const PlayerRank = z.object({ PlayerId: z.int().describe('Echoed from the request — whose rank this is'), - Score: z.int().describe('The stat value behind the rank. Always 0 — nothing is scored yet'), - Rank: z.int().describe('1-based position on the board. Always 99999 — an unranked sentinel'), + Score: z.int().describe('The player’s value on the board; 0 when they have no row there'), + Rank: z.int().describe('1-based position on the board; 99999 when the player isn’t on it'), }) /** @@ -91,9 +88,8 @@ export const CheckAndSetStatResponse = z * The body the client posts to `GetRanks`, e.g. * `{"RankStart":0,"RankEnd":9,"PlayerId":2,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`. * - * Recovered from a live client, not from a spec, and IGNORED by the handler today — the - * board is empty whatever it says. It is documented because it is the record of what the - * client asks for, which is what an implementation will have to answer. + * Recovered from a live client, not from a spec. `FilterType` 1 restricts the board to + * `PlayerId` and their friends. */ export const GetRanksBody = z.object({ RankStart: z.int().describe('First rank of the slice, 0-based and inclusive'), @@ -110,7 +106,7 @@ export const GetRanksBody = z.object({ * `{"PlayerId":205,"StatChannel":2,"RoomId":14,"FilterType":0,"SortAscending":false}`. * * The same board selectors {@link GetRanksBody} carries, minus the slice — this asks about - * one player rather than a page. Only `PlayerId` is read today, to echo it back. + * one player rather than a page. */ export const GetPlayerRankBody = z.object({ PlayerId: z.int().describe('The player whose rank is being asked for'), @@ -139,11 +135,13 @@ export const CheckAndSetStatBody = z.object({ }) /** - * The body posted to `GetNearbyScores`. Its shape has NOT been recovered from the client — - * the handler logs the raw text precisely so it can be — so this documents an open object - * rather than guessing fields. Expect it to name a player and a board the way - * {@link GetRanksBody} does. + * The body posted to `GetNearbyScores`: {@link GetPlayerRankBody} plus `WindowSize`. This + * is the reference servers' shape (the client's `GetNearbyScoresRequestDTO` extends its + * rank request with `WindowSize`); it has not yet been watched from a live client here, + * which is why the handler still logs it. */ -export const GetNearbyScoresBody = z - .looseObject({}) - .describe('Unknown shape — logged by the handler so it can be recovered from a live client.') +export const GetNearbyScoresBody = GetPlayerRankBody.extend({ + WindowSize: z + .int() + .describe('How many rows either side of the player to return; default and maximum 10'), +}) diff --git a/apps/leaderboard/src/test/integration/api.test.ts b/apps/leaderboard/src/test/integration/api.test.ts index 98eb60f..dc6eb81 100644 --- a/apps/leaderboard/src/test/integration/api.test.ts +++ b/apps/leaderboard/src/test/integration/api.test.ts @@ -1,5 +1,82 @@ -import { SELF } from 'cloudflare:test' -import { expect, it } from 'vitest' +import { adminSecretsStore, env, SELF } from 'cloudflare:test' +import { beforeAll, describe, expect, it } from 'vitest' + +import { RELATIONSHIP_SCHEMA_DDL, RelationshipType } from '@repo/domain' + +import { SCHEMA_DDL } from '../../leaderboard-db' + +beforeAll(async () => { + // Seed the shared JWT signing key into the local Secrets Store so .get() resolves. + await adminSecretsStore(env.JWT_SECRET).create(TEST_SECRET) + for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run() + // The `api` worker's relationship table — the friends filter reads it. + for (const stmt of RELATIONSHIP_SCHEMA_DDL) await env.DB.prepare(stmt).run() +}) + +const befriend = (a: number, b: number) => + env.DB.prepare( + 'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)' + ) + .bind(a, b, RelationshipType.Friend) + .run() + +// Mint a token the way the `auth` worker does, signing with the shared test key seeded +// into the JWT_SECRET store, so this worker's validation accepts it. +const TEST_SECRET = 'test-signing-key' + +function b64url(input: ArrayBuffer | string): string { + const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input) + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +async function bearer(sub: number): Promise> { + const now = Math.floor(Date.now() / 1000) + const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url( + JSON.stringify({ sub: String(sub), exp: now + 3600 }) + )}` + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(TEST_SECRET), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ) + const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signingInput)) + return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` } +} + +const post = (path: string, body: unknown, headers: Record = {}) => + SELF.fetch(`https://example.com/leaderboard/${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }) + +async function setStat( + player: number, + RoomId: number, + StatValue: number, + CurrentStatValue: number | null = null, + StatChannel = 2 +) { + const res = await post( + 'CheckAndSetStat', + { StatChannel, RoomId, StatValue, CurrentStatValue }, + await bearer(player) + ) + expect(res.status).toBe(200) + // The whole body is the number — not an envelope, not `{ value: 0 }`. + expect(await res.text()).toBe('0') +} + +const stored = (player: number, room: number, channel = 2) => + env.DB.prepare( + 'SELECT stat_value FROM leaderboard WHERE player_id = ?1 AND room_id = ?2 AND stat_channel = ?3' + ) + .bind(player, room, channel) + .first<{ stat_value: number }>() it('response with hello world', async () => { const res = await SELF.fetch('https://example.com') @@ -7,68 +84,339 @@ it('response with hello world', async () => { expect(await res.text()).toMatchInlineSnapshot(`"hello, world!"`) }) -it('answers GetNearbyScores with an empty row list', async () => { - const res = await SELF.fetch('https://example.com/leaderboard/GetNearbyScores', { - method: 'POST', - body: JSON.stringify({}), +describe('an empty board', () => { + it('answers GetNearbyScores with an empty row list', async () => { + const res = await post('GetNearbyScores', { PlayerId: 1, RoomId: 999, StatChannel: 1 }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ Rows: [] }) }) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ Rows: [] }) -}) -it('answers GetRanks with an empty row list', async () => { - const res = await SELF.fetch('https://example.com/leaderboard/GetRanks', { - method: 'POST', - body: JSON.stringify({ + it('answers GetRanks with an empty row list', async () => { + const res = await post('GetRanks', { RankStart: 0, RankEnd: 9, PlayerId: 2, StatChannel: 1, - RoomId: 6, + RoomId: 999, FilterType: 0, SortAscending: false, - }), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ Rows: [] }) }) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ Rows: [] }) -}) -it('answers GetPlayerRank with the unranked sentinel and the caller’s own id', async () => { - const res = await SELF.fetch('https://example.com/leaderboard/GetPlayerRank', { - method: 'POST', - body: JSON.stringify({ + it('answers GetPlayerRank with the unranked sentinel and the caller’s own id', async () => { + const res = await post('GetPlayerRank', { PlayerId: 205, StatChannel: 2, - RoomId: 14, + RoomId: 999, FilterType: 0, SortAscending: false, - }), + }) + expect(res.status).toBe(200) + // Three fields, no board selectors: the client pairs the answer with its own question. + // Rank is 1-based, so the sentinel has to be a big number rather than 0 — which would + // render the unranked caller as first place. + expect(await res.json()).toEqual({ PlayerId: 205, Score: 0, Rank: 99999 }) + }) + + it('answers GetPlayerRank even when the body is unreadable', async () => { + const res = await SELF.fetch('https://example.com/leaderboard/GetPlayerRank', { + method: 'POST', + body: 'not json', + }) + // A board that fails to draw is worse than one that draws the player as unranked. + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ PlayerId: 0, Score: 0, Rank: 99999 }) + }) + + it('answers GetRanks with an empty board when the body is unreadable', async () => { + const res = await SELF.fetch('https://example.com/leaderboard/GetRanks', { + method: 'POST', + body: 'not json', + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ Rows: [] }) }) - expect(res.status).toBe(200) - // Three fields, no board selectors: the client pairs the answer with its own question. - // Rank is 1-based, so the sentinel has to be a big number rather than 0 — which would - // render the unranked caller as first place. - expect(await res.json()).toEqual({ PlayerId: 205, Score: 0, Rank: 99999 }) }) -it('answers GetPlayerRank even when the body is unreadable', async () => { - const res = await SELF.fetch('https://example.com/leaderboard/GetPlayerRank', { - method: 'POST', - body: 'not json', +describe('CheckAndSetStat', () => { + it('refuses a write with no bearer token', async () => { + const res = await post('CheckAndSetStat', { + StatChannel: 2, + RoomId: 14, + StatValue: 1, + CurrentStatValue: null, + }) + expect(res.status).toBe(401) + }) + + it('stores the caller’s value on the board and answers a bare 0', async () => { + await setStat(42, 14, 3) + expect(await stored(42, 14)).toEqual({ stat_value: 3 }) + }) + + it('overwrites when the client believes nothing is stored', async () => { + await setStat(43, 14, 3) + await setStat(43, 14, 7, null) + expect(await stored(43, 14)).toEqual({ stat_value: 7 }) + }) + + it('writes only when CurrentStatValue matches what is stored', async () => { + await setStat(44, 14, 3) + await setStat(44, 14, 4, 3) + expect(await stored(44, 14)).toEqual({ stat_value: 4 }) + // A stale client that still believes 3 is stored doesn't walk the board backwards. + await setStat(44, 14, 1, 3) + expect(await stored(44, 14)).toEqual({ stat_value: 4 }) + }) + + it('keeps rooms and channels apart', async () => { + await setStat(45, 14, 3) + await setStat(45, 15, 9) + await setStat(45, 14, 5, null, 7) + expect(await stored(45, 14)).toEqual({ stat_value: 3 }) + expect(await stored(45, 15)).toEqual({ stat_value: 9 }) + expect(await stored(45, 14, 7)).toEqual({ stat_value: 5 }) }) - // A board that fails to draw is worse than one that draws the player as unranked. - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ PlayerId: 0, Score: 0, Rank: 99999 }) }) -it('answers CheckAndSetStat with a bare 0', async () => { - const res = await SELF.fetch('https://example.com/leaderboard/CheckAndSetStat', { - method: 'POST', - body: JSON.stringify({ StatChannel: 2, RoomId: 14, StatValue: 1, CurrentStatValue: null }), +describe('a scored board', () => { + // Room 100: player 1 has 10, player 2 has 30, player 3 has 20, player 4 has 20 (ties + // break on the lower id, so 3 ranks ahead of 4). + const ROOM = 100 + beforeAll(async () => { + await setStat(1, ROOM, 10) + await setStat(2, ROOM, 30) + await setStat(3, ROOM, 20) + await setStat(4, ROOM, 20) + }) + + it('answers another channel of the same room as its own, empty board', async () => { + const res = await post('GetRanks', { + RankStart: 0, + RankEnd: 9, + PlayerId: 1, + StatChannel: 3, + RoomId: ROOM, + FilterType: 0, + SortAscending: false, + }) + expect(await res.json()).toEqual({ Rows: [] }) + }) + + it('ranks highest value first with 1-based ranks', async () => { + const res = await post('GetRanks', { + RankStart: 0, + RankEnd: 9, + PlayerId: 1, + StatChannel: 2, + RoomId: ROOM, + FilterType: 0, + SortAscending: false, + }) + expect(await res.json()).toEqual({ + Rows: [ + { PlayerId: 2, Score: 30, Rank: 1 }, + { PlayerId: 3, Score: 20, Rank: 2 }, + { PlayerId: 4, Score: 20, Rank: 3 }, + { PlayerId: 1, Score: 10, Rank: 4 }, + ], + }) + }) + + it('pages the board by rank, inclusive at both ends', async () => { + const res = await post('GetRanks', { + RankStart: 2, + RankEnd: 3, + PlayerId: 1, + StatChannel: 2, + RoomId: ROOM, + FilterType: 0, + SortAscending: false, + }) + expect(await res.json()).toEqual({ + Rows: [ + { PlayerId: 3, Score: 20, Rank: 2 }, + { PlayerId: 4, Score: 20, Rank: 3 }, + ], + }) + }) + + it('ranks lowest first when asked to sort ascending', async () => { + const res = await post('GetRanks', { + RankStart: 1, + RankEnd: 2, + PlayerId: 1, + StatChannel: 2, + RoomId: ROOM, + FilterType: 0, + SortAscending: true, + }) + expect(await res.json()).toEqual({ + Rows: [ + { PlayerId: 1, Score: 10, Rank: 1 }, + { PlayerId: 3, Score: 20, Rank: 2 }, + ], + }) + }) + + it('answers a player’s own rank consistently with the page', async () => { + const res = await post('GetPlayerRank', { + PlayerId: 4, + StatChannel: 2, + RoomId: ROOM, + FilterType: 0, + SortAscending: false, + }) + expect(await res.json()).toEqual({ PlayerId: 4, Score: 20, Rank: 3 }) + }) + + it('answers a player with no row in the room as unranked', async () => { + const res = await post('GetPlayerRank', { + PlayerId: 77, + StatChannel: 2, + RoomId: ROOM, + FilterType: 0, + SortAscending: false, + }) + expect(await res.json()).toEqual({ PlayerId: 77, Score: 0, Rank: 99999 }) + }) + + it('answers the rows around a player, clamped to the board', async () => { + const res = await post('GetNearbyScores', { + PlayerId: 4, + StatChannel: 2, + RoomId: ROOM, + FilterType: 0, + SortAscending: false, + WindowSize: 1, + }) + expect(await res.json()).toEqual({ + Rows: [ + { PlayerId: 3, Score: 20, Rank: 2 }, + { PlayerId: 4, Score: 20, Rank: 3 }, + { PlayerId: 1, Score: 10, Rank: 4 }, + ], + }) + }) + + it('answers the top of the board around a player who isn’t on it', async () => { + const res = await post('GetNearbyScores', { + PlayerId: 77, + StatChannel: 2, + RoomId: ROOM, + FilterType: 0, + SortAscending: false, + WindowSize: 1, + }) + expect(await res.json()).toEqual({ + Rows: [ + { PlayerId: 2, Score: 30, Rank: 1 }, + { PlayerId: 3, Score: 20, Rank: 2 }, + { PlayerId: 4, Score: 20, Rank: 3 }, + ], + }) + }) +}) + +describe('the friends filter', () => { + // Room 200, channel 2: players 11..15 score 50, 40, 30, 20, 10. Player 14 is friends + // with 11 (14 requested) and 15 (15 requested); 12 and 13 are strangers, and 16 is a + // friend with no score. Globally 14 is 4th; among friends 2nd. + const ROOM = 200 + const board = (extra: object) => ({ + StatChannel: 2, + RoomId: ROOM, + FilterType: 1, + SortAscending: false, + ...extra, + }) + beforeAll(async () => { + await setStat(11, ROOM, 50) + await setStat(12, ROOM, 40) + await setStat(13, ROOM, 30) + await setStat(14, ROOM, 20) + await setStat(15, ROOM, 10) + await befriend(14, 11) + await befriend(15, 14) + await befriend(14, 16) + // A pending request is not a friendship. + await env.DB.prepare( + 'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (14, 12, ?1)' + ) + .bind(RelationshipType.FriendRequestSent) + .run() + }) + + it('ranks the viewer among their friends on GetRanks', async () => { + const res = await post('GetRanks', board({ PlayerId: 14, RankStart: 1, RankEnd: 10 })) + expect(await res.json()).toEqual({ + Rows: [ + { PlayerId: 11, Score: 50, Rank: 1 }, + { PlayerId: 14, Score: 20, Rank: 2 }, + { PlayerId: 15, Score: 10, Rank: 3 }, + ], + }) + }) + + it('gives the friends rank on GetPlayerRank', async () => { + const res = await post('GetPlayerRank', board({ PlayerId: 14 })) + expect(await res.json()).toEqual({ PlayerId: 14, Score: 20, Rank: 2 }) + const global = await post('GetPlayerRank', board({ PlayerId: 14, FilterType: 0 })) + expect(await global.json()).toEqual({ PlayerId: 14, Score: 20, Rank: 4 }) + }) + + it('centres GetNearbyScores on the viewer within their friends', async () => { + const res = await post('GetNearbyScores', board({ PlayerId: 14, WindowSize: 10 })) + expect(await res.json()).toEqual({ + Rows: [ + { PlayerId: 11, Score: 50, Rank: 1 }, + { PlayerId: 14, Score: 20, Rank: 2 }, + { PlayerId: 15, Score: 10, Rank: 3 }, + ], + }) + }) + + it('shows a player with no friends only themself', async () => { + const res = await post('GetRanks', board({ PlayerId: 13, RankStart: 1, RankEnd: 10 })) + expect(await res.json()).toEqual({ Rows: [{ PlayerId: 13, Score: 30, Rank: 1 }] }) + }) +}) + +describe('the nearby window', () => { + // Room 300: players 21..45 score 25..1, so 25 rows with player 33 in the middle (rank 13). + const ROOM = 300 + beforeAll(async () => { + for (let p = 21; p <= 45; p++) await setStat(p, ROOM, 46 - p) + }) + + it('caps WindowSize at 10 either side', async () => { + const res = await post('GetNearbyScores', { + PlayerId: 33, + StatChannel: 2, + RoomId: ROOM, + FilterType: 0, + SortAscending: false, + WindowSize: 100, + }) + const { Rows } = (await res.json()) as { Rows: { Rank: number }[] } + expect(Rows).toHaveLength(21) + expect(Rows[0]?.Rank).toBe(3) + expect(Rows[20]?.Rank).toBe(23) + }) + + it('defaults WindowSize to 10 when absent', async () => { + const res = await post('GetNearbyScores', { + PlayerId: 33, + StatChannel: 2, + RoomId: ROOM, + FilterType: 0, + SortAscending: false, + }) + const { Rows } = (await res.json()) as { Rows: unknown[] } + expect(Rows).toHaveLength(21) }) - expect(res.status).toBe(200) - // The whole body is the number — not an envelope, not `{ value: 0 }`. - expect(await res.text()).toBe('0') }) it('serves an openapi spec with no dangling refs', async () => { diff --git a/apps/leaderboard/wrangler.jsonc b/apps/leaderboard/wrangler.jsonc index b7cc486..aa67a17 100644 --- a/apps/leaderboard/wrangler.jsonc +++ b/apps/leaderboard/wrangler.jsonc @@ -4,6 +4,18 @@ "main": "src/leaderboard.app.ts", "compatibility_date": "2026-06-16", "compatibility_flags": ["nodejs_compat"], + // Shared `recflare` D1 database. This worker owns the `leaderboard` table + // (migrations/), applied under its own migrations table so they don't clash with the + // other workers' migrations that share the database. + "d1_databases": [ + { + "binding": "DB", + "database_name": "recflare", + "database_id": "local", + "migrations_dir": "migrations", + "migrations_table": "d1_migrations_leaderboard" + } + ], // Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the // same store as JWT_SECRET so tokens signed by `auth` verify here. The "local" // store_id placeholder is replaced with RECFLARE_SECRETS_STORE at deploy time. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17956cf..e31b89d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -669,6 +669,9 @@ importers: apps/leaderboard: dependencies: + '@repo/domain': + specifier: workspace:* + version: link:../../packages/domain '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers