@pithy-sh/leaderboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,261 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+ import { assertValidSchedule } from "../window/schedule";
6
+ import { BOARD_KEY_PATTERN } from "./boardKey";
7
+
8
+ /**
9
+ * The leaderboard capability's config — the thin, user-owned surface in `pithy.config.ts`. Every field
10
+ * is `.describe()`d: the descriptions feed the self-documenting CLI, so a non-expert can pick the right
11
+ * board shape and the right `rank` mode from the CLI's questions alone (CLAUDE.md §Config).
12
+ *
13
+ * A board definition is the unit of config. There is no boards table to administer and no dashboard to
14
+ * click through — the board set is code, reviewed and deployed like the rest of the app. What the
15
+ * database records is only what config cannot: the entries, and a fingerprint of each board's immutable
16
+ * fields so a later edit cannot silently reinterpret scores already stored (see `data/boardRecord`).
17
+ */
18
+
19
+ export const ScoreDirection = z
20
+ .enum(["asc", "desc"])
21
+ .describe(
22
+ "Which way a score sorts: `desc` = highest wins (points, distance), `asc` = lowest wins (lap time, strokes). Immutable after the board records its first entry — every vendor surveyed treats it that way, and flipping it would silently reinterpret every stored score.",
23
+ );
24
+ export type ScoreDirection = z.infer<typeof ScoreDirection>;
25
+
26
+ export const ScoreAggregation = z
27
+ .enum(["best", "latest", "sum"])
28
+ .describe(
29
+ "How repeat submissions combine into one entry: `best` keeps the best score in the board's direction, `latest` overwrites, `sum` accumulates. Orthogonal to direction — only `best` reads it.",
30
+ );
31
+ export type ScoreAggregation = z.infer<typeof ScoreAggregation>;
32
+
33
+ export const LeaderboardStore = z
34
+ .literal("d1")
35
+ .describe(
36
+ "Which backing store ranks this board. Today the only value is `d1`: exact ranking on your own D1, the store this whole capability is built on. It is a per-board discriminant on purpose — the issue settled `Store: D1, always` against a Durable-Object *engine* flag (a DO fixes neither cost nor throughput), but a future column-oriented store is a different axis: an Analytics-Engine-shaped board would be far cheaper at scale yet *approximate* (it samples on read and write), so it can only ever back a separate, opt-in `approximate` board type — never replace exact `d1` ranking. This field is the seam that would let such a board live beside a `d1` one, chosen per board. It is immutable once a board records entries: there is no safe automatic migration of scores between stores.",
37
+ );
38
+ export type LeaderboardStore = z.infer<typeof LeaderboardStore>;
39
+
40
+ export const LeaderboardTier = z
41
+ .object({
42
+ key: z
43
+ .string()
44
+ .min(1)
45
+ .describe("The tier's name as returned to clients — `gold`, `diamond`, whatever your game calls it."),
46
+ from: z
47
+ .number()
48
+ .describe(
49
+ "The score at which this tier begins, inclusive. Read in the board's direction: on a `desc` board a score at or above `from` qualifies; on an `asc` board a score at or below it does.",
50
+ ),
51
+ })
52
+ .describe("One tier threshold — a named band of scores, classified on read.");
53
+ export type LeaderboardTier = z.infer<typeof LeaderboardTier>;
54
+
55
+ export const LeaderboardBoard = z
56
+ .object({
57
+ key: z
58
+ .string()
59
+ .regex(BOARD_KEY_PATTERN, "A board key is lowercase, digits, and dashes — it is a URL path segment.")
60
+ .describe("The board's stable id, unique across the app. It is a URL path segment and an entry key."),
61
+ store: LeaderboardStore.default("d1").describe("Which backing store ranks this board. Only `d1` today."),
62
+ direction: ScoreDirection.describe("Which way this board's scores sort. Required — there is no safe default."),
63
+ aggregation: ScoreAggregation.default("best").describe(
64
+ "How this board folds repeat submissions from the same player in the same window.",
65
+ ),
66
+ window: z
67
+ .string()
68
+ .optional()
69
+ .describe(
70
+ "A UTC CRON expression marking where each window opens; omit for one all-time board. `0 0 * * *` is daily, `0 0 * * 1` weekly, `0 0 1 * *` a calendar month, `0 0 1 1 *` a calendar year. CRON rather than a fixed enum is what makes calendar months and years expressible at all — Apple caps recurrence at 30 fixed days and Google offers no monthly.",
71
+ ),
72
+ retain: z
73
+ .number()
74
+ .int()
75
+ .min(0)
76
+ .optional()
77
+ .describe(
78
+ 'How many closed windows to keep before the retention sweep deletes the rest — a *product* limit ("users can browse the last 12 weeks"). Omit to keep every window forever, which is the default: storage is never the cost driver here (see docs/costs.md), so nothing is deleted unless you ask. Ignored on an all-time board, which never closes a window. Set this OR `retainDays`, not both.',
79
+ ),
80
+ retainDays: z
81
+ .number()
82
+ .int()
83
+ .min(1)
84
+ .optional()
85
+ .describe(
86
+ 'Delete windows whose data is older than this many days — a *compliance* limit ("nothing older than 90 days"), independent of window cadence. Omit to keep everything (the default). Ignored on an all-time board, whose single window has no age. Set this OR `retain`, not both.',
87
+ ),
88
+ min: z
89
+ .number()
90
+ .optional()
91
+ .describe(
92
+ "The lowest score this board will accept. Server-side bounds are the anti-cheat baseline: a client that posts outside them is rejected, not ranked.",
93
+ ),
94
+ max: z.number().optional().describe("The highest score this board will accept."),
95
+ tiers: z
96
+ .array(LeaderboardTier)
97
+ .optional()
98
+ .describe(
99
+ "Named score bands, listed worst to best. Classified on read from the score already stored — a tier costs nothing on write and no second board.",
100
+ ),
101
+ trackActivity: z
102
+ .boolean()
103
+ .default(false)
104
+ .describe(
105
+ "Whether every submission is written, or only ones that change the ranked score. Default `false`: on a `best` board a submission that fails to beat the stored score writes *nothing* — zero rows billed — because the guard skips it. That is the single biggest cost lever this capability has, since submission writes dominate the bill and most submissions do not improve a player's best (see docs/costs.md). The cost is that `submittedAt` then advances only on an improving submission, so it stops being a record of *activity* and becomes a record of *progress*. Set `true` to write on every submission and keep `submittedAt` a true last-seen timestamp — at full write cost. Ignored on `sum` and `latest` boards, where every submission changes the score and therefore always writes.",
106
+ ),
107
+ })
108
+ .describe("One board definition — the unit of leaderboard config.")
109
+ .check((ctx) => {
110
+ const { min, max, window, tiers, direction, retain, retainDays } = ctx.value;
111
+ if (min !== undefined && max !== undefined && min > max) {
112
+ ctx.issues.push({
113
+ code: "custom",
114
+ input: ctx.value,
115
+ path: ["min"],
116
+ message: `Board "${ctx.value.key}" sets min ${min} above max ${max}, so every score would be rejected.`,
117
+ });
118
+ }
119
+ if (retain !== undefined && retainDays !== undefined) {
120
+ // Both would need a union-or-intersection rule that the product ("keep N windows") and the
121
+ // compliance ("delete after N days") intents pull in opposite directions on. Forcing one keeps
122
+ // the meaning unambiguous.
123
+ ctx.issues.push({
124
+ code: "custom",
125
+ input: ctx.value,
126
+ path: ["retainDays"],
127
+ message: `Board "${ctx.value.key}" sets both retain and retainDays. Set one: retain for a window-count limit, retainDays for an age limit.`,
128
+ });
129
+ }
130
+ if ((retain !== undefined || retainDays !== undefined) && window === undefined) {
131
+ // An all-time board has one never-closing window; there is nothing for a window-based limit to
132
+ // prune, and age-based deletion of live entries is out of scope for v1.
133
+ ctx.issues.push({
134
+ code: "custom",
135
+ input: ctx.value,
136
+ path: [retainDays !== undefined ? "retainDays" : "retain"],
137
+ message: `Board "${ctx.value.key}" is all-time (no window), so retention does not apply. Remove retain/retainDays or give the board a window.`,
138
+ });
139
+ }
140
+ if (window !== undefined) {
141
+ try {
142
+ assertValidSchedule(window);
143
+ } catch (error) {
144
+ ctx.issues.push({
145
+ code: "custom",
146
+ input: ctx.value,
147
+ path: ["window"],
148
+ message: `Board "${ctx.value.key}" has an invalid window schedule: ${(error as Error).message}`,
149
+ });
150
+ }
151
+ }
152
+ if (tiers) {
153
+ // Worst-to-best in the board's direction: ascending thresholds on `desc`, descending on `asc`.
154
+ // An out-of-order tier is unreachable, and silently unreachable is worse than rejected.
155
+ for (let i = 1; i < tiers.length; i++) {
156
+ const previous = tiers[i - 1] as LeaderboardTier;
157
+ const current = tiers[i] as LeaderboardTier;
158
+ const improves = direction === "desc" ? current.from > previous.from : current.from < previous.from;
159
+ if (!improves) {
160
+ ctx.issues.push({
161
+ code: "custom",
162
+ input: ctx.value,
163
+ path: ["tiers", i, "from"],
164
+ message: `Board "${ctx.value.key}" lists tier "${current.key}" after "${previous.key}", but its threshold does not improve on a ${direction} board. List tiers worst to best.`,
165
+ });
166
+ }
167
+ }
168
+ }
169
+ });
170
+ export type LeaderboardBoard = z.output<typeof LeaderboardBoard>;
171
+
172
+ export const LeaderboardRank = z
173
+ .union([
174
+ z
175
+ .literal("live")
176
+ .describe("Rank is counted at read time. Always correct, no moving parts, and $0 under ~10k players."),
177
+ z
178
+ .object({
179
+ materialize: z
180
+ .string()
181
+ .describe(
182
+ "A UTC CRON expression for the rank refresh pass. `0 * * * *` is hourly. Faster means fresher ranks and a bigger bill; the cadence is a dial, not a switch.",
183
+ ),
184
+ })
185
+ .describe("Rank is stored on the entry and refreshed on a schedule, making my-rank a single point read."),
186
+ ])
187
+ .describe(
188
+ "How rank is computed. `live` counts better entries per request — correct and free for small boards, and quadratic as they grow. `{ materialize }` trades rank staleness for cost and is the documented path past ~100k players; a player's own score stays live either way. See docs/costs.md before choosing.",
189
+ );
190
+ export type LeaderboardRank = z.output<typeof LeaderboardRank>;
191
+
192
+ export const LeaderboardConfig = z
193
+ .object({
194
+ boards: z
195
+ .array(LeaderboardBoard)
196
+ .min(1, "A leaderboard capability with no boards does nothing — configure at least one.")
197
+ .describe("Every board this app ranks. The board set is config, not database rows."),
198
+ rank: LeaderboardRank.prefault("live").describe("How rank is computed across every board."),
199
+ serverAuthoritative: z
200
+ .boolean()
201
+ .default(true)
202
+ .describe(
203
+ "Require the submit scope to post a score. On by default, inverting the vendor norm — every platform that offers server-authoritative writes ships it off. Leave it on and submit from your trusted server; turn it off only if you accept that a player's device can post any score it likes.",
204
+ ),
205
+ submitScope: z
206
+ .string()
207
+ .min(1)
208
+ .default("leaderboard:submit")
209
+ .describe(
210
+ "The AuthContext scope a session must carry to submit a score while `serverAuthoritative` is on. Mint it for your server's own token, never for a player's.",
211
+ ),
212
+ adminScope: z
213
+ .string()
214
+ .min(1)
215
+ .default("leaderboard:admin")
216
+ .describe("The AuthContext scope required to hide or remove another player's entry."),
217
+ visibleByDefault: z
218
+ .boolean()
219
+ .default(true)
220
+ .describe(
221
+ "Whether a new entry appears on the board before the player says otherwise. Set false to make the board opt-in, so a player only shows up once they consent.",
222
+ ),
223
+ })
224
+ .describe("Configuration for the leaderboard capability — the board set, how rank is computed, and who may write.")
225
+ .check((ctx) => {
226
+ const keys = ctx.value.boards.map((b) => b.key);
227
+ const duplicates = [...new Set(keys.filter((key, i) => keys.indexOf(key) !== i))];
228
+ if (duplicates.length > 0) {
229
+ ctx.issues.push({
230
+ code: "custom",
231
+ input: ctx.value,
232
+ path: ["boards"],
233
+ message: `Duplicate board keys: ${duplicates.join(", ")}. Two boards sharing a key would merge their entries.`,
234
+ });
235
+ }
236
+ const rank = ctx.value.rank;
237
+ if (typeof rank === "object") {
238
+ try {
239
+ assertValidSchedule(rank.materialize);
240
+ } catch (error) {
241
+ ctx.issues.push({
242
+ code: "custom",
243
+ input: ctx.value,
244
+ path: ["rank", "materialize"],
245
+ message: `Invalid materialize schedule: ${(error as Error).message}`,
246
+ });
247
+ }
248
+ }
249
+ });
250
+ export type LeaderboardConfig = z.output<typeof LeaderboardConfig>;
251
+ export type LeaderboardConfigInput = z.input<typeof LeaderboardConfig>;
252
+
253
+ /** The board with this key, or undefined. Board keys come from config, so an unknown key is a 404. */
254
+ export function resolveBoard(config: LeaderboardConfig, key: string): LeaderboardBoard | undefined {
255
+ return config.boards.find((board) => board.key === key);
256
+ }
257
+
258
+ /** The rank refresh CRON, or undefined when rank is live and no refresh worker is needed. */
259
+ export function materializeSchedule(config: LeaderboardConfig): string | undefined {
260
+ return typeof config.rank === "object" ? config.rank.materialize : undefined;
261
+ }
@@ -0,0 +1,39 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+ import { LeaderboardStore, ScoreAggregation, ScoreDirection } from "../config/config";
7
+
8
+ /**
9
+ * The recorded definition of a board that has started taking entries — the row in
10
+ * `pithy_leaderboard_boards`.
11
+ *
12
+ * Boards are config, not database rows, so this table holds no board *settings*. It holds only the three
13
+ * fields that may never change once a score exists, so that changing one in `pithy.config.ts` fails loudly
14
+ * instead of silently reinterpreting stored data:
15
+ *
16
+ * - `direction` — flipping it turns every leader into a laggard.
17
+ * - `aggregation` — switching `best` to `sum` makes existing rows mean something they never meant.
18
+ * - `window` — a new schedule keys new scores into windows that do not line up with the stored ones.
19
+ *
20
+ * Every vendor surveyed treats these as create-time and immutable. Pithy cannot enforce that in the type
21
+ * system because the board set is config the adopter edits freely, so it enforces it here, on first write
22
+ * of each window, against what was actually recorded.
23
+ */
24
+ export const LeaderboardBoardRecord = z
25
+ .object({
26
+ id: z.number().int().describe("Autoincrement primary key."),
27
+ boardKey: z.string().describe("The board key this definition belongs to. Unique — one record per board."),
28
+ store: LeaderboardStore.describe("The backing store recorded when this board took its first entry."),
29
+ direction: ScoreDirection.describe("The direction recorded when this board took its first entry."),
30
+ aggregation: ScoreAggregation.describe("The aggregation recorded when this board took its first entry."),
31
+ window: z
32
+ .string()
33
+ .nullish()
34
+ .describe("The window CRON recorded when this board took its first entry; null on an all-time board."),
35
+ createdAt: SQLiteDate.describe("When this board first recorded an entry."),
36
+ })
37
+ .describe("The immutable fields of a board that has started recording entries — the drift guard.");
38
+ export type LeaderboardBoardRecord = z.output<typeof LeaderboardBoardRecord>;
39
+ export type LeaderboardBoardRecordRow = z.input<typeof LeaderboardBoardRecord>;
@@ -0,0 +1,59 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteBoolean, SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+
7
+ /**
8
+ * One player's state on one board in one window — the row in `pithy_leaderboard_entries`.
9
+ *
10
+ * `z.output` is the app shape (Dates, booleans); `z.input` is the SQLite row (ms-epoch, 0|1). All
11
+ * JS↔SQLite conversion runs through the core codecs — no raw `0/1`, epoch, or `new Date()` in query code.
12
+ *
13
+ * The entry is keyed `(boardId, windowKey, userId)`: each window carries its own aggregation state rather
14
+ * than being a query-time filter over an append log. That is what makes `best` and `sum` expressible per
15
+ * window at all, and what keeps a read O(one row) instead of O(a player's whole history).
16
+ *
17
+ * `id` is an autoincrement integer, not a UUID: an entry id is never handed to a client or embedded in a
18
+ * URL — every route addresses an entry by its natural key — so there is nothing to enumerate.
19
+ */
20
+ export const LeaderboardEntry = z
21
+ .object({
22
+ id: z.number().int().describe("Autoincrement primary key. Internal only; entries are addressed by natural key."),
23
+ boardId: z.string().describe("The board key this entry ranks on, from `boards` in pithy.config.ts."),
24
+ windowKey: z
25
+ .string()
26
+ .describe(
27
+ "The window this entry belongs to: the ISO instant the board's CRON last fired at or before the score, or `all` on an all-time board.",
28
+ ),
29
+ userId: z
30
+ .string()
31
+ .describe(
32
+ "The authenticated player, from the core AuthContext seam. Never read from the request body — that would let any caller score as anyone.",
33
+ ),
34
+ score: z
35
+ .number()
36
+ .describe("The player's current score for this window, already folded by the board's aggregation."),
37
+ achievedAt: SQLiteDate.describe(
38
+ "When the current score was first reached. The primary tiebreak: on equal scores the player who got there first ranks higher.",
39
+ ),
40
+ submittedAt: SQLiteDate.describe(
41
+ "When this entry was last written. Distinct from achievedAt — a submission that fails to improve a `best` board still touches this.",
42
+ ),
43
+ visible: SQLiteBoolean.describe(
44
+ "Whether the player consents to appear. Gates every read, including friends and segment queries.",
45
+ ),
46
+ hidden: SQLiteBoolean.describe(
47
+ "Whether an admin has hidden this entry. Distinct from `visible` so a moderator action cannot be undone by the player toggling their own consent.",
48
+ ),
49
+ rank: z
50
+ .number()
51
+ .int()
52
+ .nullish()
53
+ .describe(
54
+ "The materialized rank, refreshed by the rank worker. Null while rank is live, and null on a fresh entry until the next refresh pass reaches it.",
55
+ ),
56
+ })
57
+ .describe("One player's entry on one board in one window — the row in `pithy_leaderboard_entries`.");
58
+ export type LeaderboardEntry = z.output<typeof LeaderboardEntry>;
59
+ export type LeaderboardEntryRow = z.input<typeof LeaderboardEntry>;
@@ -0,0 +1,26 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
5
+ import { z } from "zod";
6
+
7
+ /**
8
+ * A single-row advisory lock in `pithy_leaderboard_locks` — the row in the table, one per lock name.
9
+ *
10
+ * It serializes the rank-refresh Workflow: at most one instance holds the `rank-refresh` lock at a time,
11
+ * so two overlapping cron fires can never interleave their chunked rank writes into an incoherent set.
12
+ *
13
+ * `z.output` is the app shape (a Date); `z.input` is the SQLite row (ms-epoch). Conversion runs through
14
+ * the core codec, per the round-trip rule.
15
+ */
16
+ export const LeaderboardLock = z
17
+ .object({
18
+ name: z.string().describe("The lock's identity — `rank-refresh` for the refresh pass. One row per name."),
19
+ holder: z.string().describe("A token unique to the instance currently holding the lock."),
20
+ acquiredAt: SQLiteDate.describe(
21
+ "When the current holder took the lock. A lock older than the stale horizon is treated as abandoned by a crashed instance and may be reclaimed.",
22
+ ),
23
+ })
24
+ .describe("One advisory lock in `pithy_leaderboard_locks`.");
25
+ export type LeaderboardLock = z.output<typeof LeaderboardLock>;
26
+ export type LeaderboardLockRow = z.input<typeof LeaderboardLock>;
@@ -0,0 +1,43 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ import { createDatabase, type DatabaseSchema } from "@pithy-sh/core/src/data/db";
6
+ import type { Kysely } from "kysely";
7
+ import type { z } from "zod";
8
+ import { LeaderboardBoardRecord } from "./boardRecord";
9
+ import { LeaderboardEntry } from "./entry";
10
+ import { LeaderboardLock } from "./lock";
11
+
12
+ /** The entry table. `CamelCasePlugin` snake-cases it to `pithy_leaderboard_entries` in the DDL. */
13
+ export const LEADERBOARD_ENTRIES_TABLE = "pithyLeaderboardEntries";
14
+ /** The board drift-guard table. `CamelCasePlugin` snake-cases it to `pithy_leaderboard_boards`. */
15
+ export const LEADERBOARD_BOARDS_TABLE = "pithyLeaderboardBoards";
16
+ /** The advisory-lock table. `CamelCasePlugin` snake-cases it to `pithy_leaderboard_locks`. */
17
+ export const LEADERBOARD_LOCKS_TABLE = "pithyLeaderboardLocks";
18
+
19
+ /** The leaderboard tables map. All are always present — none is behind a config flag. */
20
+ export function leaderboardTables(): Record<string, z.ZodObject> {
21
+ return {
22
+ [LEADERBOARD_ENTRIES_TABLE]: LeaderboardEntry,
23
+ [LEADERBOARD_BOARDS_TABLE]: LeaderboardBoardRecord,
24
+ [LEADERBOARD_LOCKS_TABLE]: LeaderboardLock,
25
+ };
26
+ }
27
+
28
+ /** The typed Kysely database over the leaderboard tables. */
29
+ export type LeaderboardTables = {
30
+ [LEADERBOARD_ENTRIES_TABLE]: typeof LeaderboardEntry;
31
+ [LEADERBOARD_BOARDS_TABLE]: typeof LeaderboardBoardRecord;
32
+ [LEADERBOARD_LOCKS_TABLE]: typeof LeaderboardLock;
33
+ };
34
+ export type LeaderboardDatabase = Kysely<DatabaseSchema<LeaderboardTables>>;
35
+
36
+ /** Build the leaderboard database from the `DB` binding (CamelCasePlugin installed). */
37
+ export function leaderboardDatabase(d1: D1Database): LeaderboardDatabase {
38
+ return createDatabase(d1, {
39
+ [LEADERBOARD_ENTRIES_TABLE]: LeaderboardEntry,
40
+ [LEADERBOARD_BOARDS_TABLE]: LeaderboardBoardRecord,
41
+ [LEADERBOARD_LOCKS_TABLE]: LeaderboardLock,
42
+ }) as unknown as LeaderboardDatabase;
43
+ }
@@ -0,0 +1,184 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { type SqlBool, sql } from "kysely";
5
+ import type { LeaderboardBoard } from "../config/config";
6
+ import { LeaderboardEntry } from "../data/entry";
7
+ import { LEADERBOARD_ENTRIES_TABLE, type LeaderboardDatabase } from "../data/tables";
8
+
9
+ /**
10
+ * Reads and writes over `pithy_leaderboard_entries`.
11
+ *
12
+ * Every write is one `INSERT … ON CONFLICT DO UPDATE` against the `(boardId, windowKey, userId)` unique
13
+ * index. Folding the aggregation into the conflict clause — rather than reading the row, deciding in JS,
14
+ * and writing it back — is what keeps a submission a single statement: D1 is single-threaded, so a
15
+ * read-then-write would both double the round trips and open a race where two submissions from the same
16
+ * player interleave and one silently wins.
17
+ *
18
+ * Round-trip rule (CLAUDE.md §Data layer): read with `LeaderboardEntry.parse` (decode), write with
19
+ * `LeaderboardEntry.encode` (encode). No raw `0/1` or epoch numbers below.
20
+ */
21
+ export interface EntryStore {
22
+ /** Fold a submission into the player's entry for this window, per the board's aggregation. */
23
+ submit(
24
+ board: LeaderboardBoard,
25
+ windowKey: string,
26
+ userId: string,
27
+ score: number,
28
+ at: Date,
29
+ visibleByDefault: boolean,
30
+ ): Promise<void>;
31
+ /** The player's entry for this window, or undefined if they have never submitted. */
32
+ get(boardId: string, windowKey: string, userId: string): Promise<LeaderboardEntry | undefined>;
33
+ /** Set the player's own consent to appear. False if they have no entry. */
34
+ setVisibility(boardId: string, windowKey: string, userId: string, visible: boolean): Promise<boolean>;
35
+ /** Hide or unhide an entry as a moderator. False if they have no entry. */
36
+ hide(boardId: string, windowKey: string, userId: string, hidden: boolean): Promise<boolean>;
37
+ /** Delete an entry outright. False if they have no entry. */
38
+ remove(boardId: string, windowKey: string, userId: string): Promise<boolean>;
39
+ /** Delete entries on this board in windows chronologically before `cutoffWindow`. Returns rows deleted. */
40
+ pruneWindowsBefore(boardId: string, cutoffWindow: string): Promise<number>;
41
+ }
42
+
43
+ /**
44
+ * The submission upsert, as a compilable query.
45
+ *
46
+ * Exported so `writeAmplification.workers.test.ts` can compile the real statement and read D1's own
47
+ * `meta.rows_written` for it. The cost model in `scripts/costModel.ts` assumes a fixed number of rows
48
+ * written per submission, and that assumption is load-bearing for every figure in docs/costs.md — a test
49
+ * that measured a hand-written approximation of this SQL would only prove the approximation's cost.
50
+ */
51
+ export function submitQuery(
52
+ db: LeaderboardDatabase,
53
+ board: LeaderboardBoard,
54
+ windowKey: string,
55
+ userId: string,
56
+ score: number,
57
+ at: Date,
58
+ visibleByDefault: boolean,
59
+ ) {
60
+ const insert = LeaderboardEntry.encode({
61
+ id: 0,
62
+ boardId: board.key,
63
+ windowKey,
64
+ userId,
65
+ score,
66
+ achievedAt: at,
67
+ submittedAt: at,
68
+ visible: visibleByDefault,
69
+ hidden: false,
70
+ rank: null,
71
+ }) as Record<string, unknown>;
72
+ // `id` is autoincrement — let SQLite assign it rather than inserting the placeholder above.
73
+ delete insert.id;
74
+
75
+ const entries = sql.table(LEADERBOARD_ENTRIES_TABLE);
76
+ const improves =
77
+ board.direction === "desc"
78
+ ? sql<SqlBool>`excluded.score > ${entries}.score`
79
+ : sql<SqlBool>`excluded.score < ${entries}.score`;
80
+
81
+ // On a `best` board with `trackActivity: false` (the default), a submission that does not beat the
82
+ // stored score should cost nothing. A `WHERE` on the conflict clause makes SQLite skip the row
83
+ // entirely — 0 rows written, versus 3 for a row it touches — which is the capability's biggest cost
84
+ // lever, since most submissions do not improve a player's best. `sum`/`latest` always change the
85
+ // score, so they always write and this guard never applies to them.
86
+ const guardNoOps = board.aggregation === "best" && !board.trackActivity;
87
+
88
+ const conflict = db
89
+ .insertInto(LEADERBOARD_ENTRIES_TABLE)
90
+ // biome-ignore lint/suspicious/noExplicitAny: the encoded row is the schema's `z.input` side; Kysely's insert type is derived from it.
91
+ .values(insert as any)
92
+ .onConflict((oc) => {
93
+ const updated = oc.columns(["boardId", "windowKey", "userId"]).doUpdateSet(() => {
94
+ // A submission advances submittedAt when it writes. With `trackActivity: false` a non-improving
95
+ // `best` submission is skipped by the guard below and never reaches here, so submittedAt tracks
96
+ // progress rather than activity. Neither the player's consent nor a moderator's hide is the
97
+ // submitter's to reset, so `visible` and `hidden` are absent from every branch.
98
+ const submittedAt = sql<number>`excluded.submitted_at`;
99
+ if (board.aggregation === "latest") {
100
+ return { submittedAt, score: sql<number>`excluded.score`, achievedAt: sql<number>`excluded.achieved_at` };
101
+ }
102
+ if (board.aggregation === "sum") {
103
+ return {
104
+ submittedAt,
105
+ score: sql<number>`${entries}.score + excluded.score`,
106
+ achievedAt: sql<number>`excluded.achieved_at`,
107
+ };
108
+ }
109
+ // `best`: take the new score only if it improves in the board's direction. On an equal score
110
+ // both branches keep the stored achievedAt, so a replay cannot cost a player the first-to-reach
111
+ // tiebreak they already earned.
112
+ return {
113
+ submittedAt,
114
+ score: sql<number>`CASE WHEN ${improves} THEN excluded.score ELSE ${entries}.score END`,
115
+ achievedAt: sql<number>`CASE WHEN ${improves} THEN excluded.achieved_at ELSE ${entries}.achieved_at END`,
116
+ };
117
+ });
118
+ return guardNoOps ? updated.where(improves) : updated;
119
+ });
120
+
121
+ return conflict;
122
+ }
123
+
124
+ export function entryStore(db: LeaderboardDatabase): EntryStore {
125
+ return {
126
+ async submit(board, windowKey, userId, score, at, visibleByDefault) {
127
+ await submitQuery(db, board, windowKey, userId, score, at, visibleByDefault).execute();
128
+ },
129
+
130
+ async get(boardId, windowKey, userId) {
131
+ const row = await db
132
+ .selectFrom(LEADERBOARD_ENTRIES_TABLE)
133
+ .selectAll()
134
+ .where("boardId", "=", boardId)
135
+ .where("windowKey", "=", windowKey)
136
+ .where("userId", "=", userId)
137
+ .executeTakeFirst();
138
+ return row ? LeaderboardEntry.parse(row) : undefined;
139
+ },
140
+
141
+ async setVisibility(boardId, windowKey, userId, visible) {
142
+ const result = await db
143
+ .updateTable(LEADERBOARD_ENTRIES_TABLE)
144
+ .set({ visible: LeaderboardEntry.shape.visible.encode(visible) })
145
+ .where("boardId", "=", boardId)
146
+ .where("windowKey", "=", windowKey)
147
+ .where("userId", "=", userId)
148
+ .executeTakeFirst();
149
+ return Number(result.numUpdatedRows) > 0;
150
+ },
151
+
152
+ async hide(boardId, windowKey, userId, hidden) {
153
+ const result = await db
154
+ .updateTable(LEADERBOARD_ENTRIES_TABLE)
155
+ .set({ hidden: LeaderboardEntry.shape.hidden.encode(hidden) })
156
+ .where("boardId", "=", boardId)
157
+ .where("windowKey", "=", windowKey)
158
+ .where("userId", "=", userId)
159
+ .executeTakeFirst();
160
+ return Number(result.numUpdatedRows) > 0;
161
+ },
162
+
163
+ async remove(boardId, windowKey, userId) {
164
+ const result = await db
165
+ .deleteFrom(LEADERBOARD_ENTRIES_TABLE)
166
+ .where("boardId", "=", boardId)
167
+ .where("windowKey", "=", windowKey)
168
+ .where("userId", "=", userId)
169
+ .executeTakeFirst();
170
+ return Number(result.numDeletedRows) > 0;
171
+ },
172
+
173
+ async pruneWindowsBefore(boardId, cutoffWindow) {
174
+ // Window keys are ISO instants, so `<` is chronological. `all` is never a windowed board's key, so
175
+ // an all-time window (which retention never targets) cannot be caught here.
176
+ const result = await db
177
+ .deleteFrom(LEADERBOARD_ENTRIES_TABLE)
178
+ .where("boardId", "=", boardId)
179
+ .where("windowKey", "<", cutoffWindow)
180
+ .executeTakeFirst();
181
+ return Number(result.numDeletedRows);
182
+ },
183
+ };
184
+ }