@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pithy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # @pithy-sh/leaderboard
2
+
3
+ Rank your players. Submit a score, read the standings — daily through all-time.
4
+
5
+ Boards run in your Cloudflare account, on your D1, against your data. Closed windows stay as long as you say, in plain SQL you can join against your own tables. There is no Pithy service in the path and no Pithy bill.
6
+
7
+ ```sh
8
+ pithy add leaderboard
9
+ ```
10
+
11
+ **Documentation: [pithy.sh/docs/capabilities/leaderboard](https://pithy.sh/docs/capabilities/leaderboard).** Overview, adding it, using it, and the reference: boards and windows, ranking modes, what it costs.
12
+
13
+ _Everything else is on the site. `pithy.sh/docs` is canonical — new prose goes there, not here._
14
+
15
+ ## License
16
+
17
+ MIT — adopter-side app value. The root `LICENSE` covers it.
package/docs/costs.md ADDED
@@ -0,0 +1,102 @@
1
+ # What a leaderboard costs
2
+
3
+ _The reader's version of this page is [pithy.sh/docs/capabilities/leaderboard/costs](https://pithy.sh/docs/capabilities/leaderboard/costs). This copy ships in the package because `packages/leaderboard/src/config/config.ts` sends an adopter to it by name._
4
+
5
+ **As of 2026-07-16.** Cloudflare's [D1 pricing page](https://developers.cloudflare.com/d1/platform/pricing/) is the authority. Prices, included allowances, and limits can change at any time, and the allowances do most of the work in the numbers below — a change to the 25 billion rows-read allowance alone would move every boundary in this table. Nothing here is a Cloudflare quote, and none of it is a bill you can plan against to the dollar. It is directional engine guidance.
6
+
7
+ **Pithy never bills you.** This is Cloudflare metering your own Cloudflare account, on prices Pithy does not set and cannot control. We take no cut of any of it.
8
+
9
+ **We are not the cheapest option, and this page is not a sales pitch.** The free platform SDKs — Game Center, Play Games Services, Steam — cost nothing at any scale, and PlayFab is a few times cheaper than us. See [pithy.sh/docs/capabilities/leaderboard/differentiation](https://pithy.sh/docs/capabilities/leaderboard/differentiation) for the full comparison; the short version is that you pay here for cross-platform reach and for owning your data in your own SQL, not for a lower bill. This page exists so you can see that bill before you commit — the mode you pick at 1,000 players decides what it looks like at a million, and growth is the good outcome that still has a cost.
10
+
11
+ ## The table
12
+
13
+ | Players | `rank: "live"` | `materialize` daily | `materialize` hourly | Storage | Upserts/sec |
14
+ |---:|---:|---:|---:|---:|---:|
15
+ | 1,000 | **$0** | $0 | $0 | 0.00 GB | ~0 |
16
+ | 10,000 | **$0** | $0 | $0 | 0.00 GB | 2 |
17
+ | 100,000 | **$765** | **$49** | $256 | 0.03 GB | 17 |
18
+ | 1,000,000 | **$75,825** | **$940** | $3,010 | 0.30 GB | **174** |
19
+ | 10,000,000 | **$7,508,925** | **$9,850** | $30,550 | 3.00 GB | **1,736** |
20
+
21
+ Per month, USD. Reproduce it yourself: `bun run --filter @pithy-sh/leaderboard costs`.
22
+
23
+ ## Reading it
24
+
25
+ **Live rank is free to ~10k players and ruinous past 100k.** Under about ten thousand players the 25B/month read allowance absorbs everything, so `rank: "live"` is correct and costs nothing. That is most adopters, and it is the default. Do not engineer anything.
26
+
27
+ **Live rank is quadratic.** D1 bills rows *scanned*, not returned — "a query that filters on an unindexed column may return fewer rows to your Worker, but is still required to read (scan) more rows". A live rank counts every entry that beats you, so each check scans a slice of the board that grows with the board, while the number of checks grows with the player count too. Ten times the players is about a hundred times the read bill. That is the whole shape of the first column.
28
+
29
+ **Materialization is the fix, and it is pure D1.** Roughly $75,825 becomes $940 at a million players. It is not a different database, a different engine, or a different product — it is a stored rank column and a cron. Refresh cadence is a continuous dial trading staleness for cost, and it stops paying for itself somewhere between hourly and every fifteen minutes. Your own score stays live in both modes, which hides most of the staleness from the player who cares most about it.
30
+
31
+ **Past ~1M players the dominant term flips.** At ten million, materialized-daily is $9,850/month, of which **$8,950 is submission writes** — not the refresh. Beyond that point no cadence tuning helps. Only fewer windows or fewer submissions do.
32
+
33
+ **Storage never binds.** Three gigabytes at ten million players, against a 10 GB cap.
34
+
35
+ ## The table is the worst case. Your board does better.
36
+
37
+ Every figure above assumes *every* submission writes. On the default `best` board it doesn't: a submission that fails to beat a player's stored score is skipped by the upsert's `WHERE` guard and writes **zero rows** (`trackActivity: false`, the default). Since submission writes are the dominant term — 85% of the bill past a million players — and most submissions don't improve a player's best, a real board pays a fraction of the table.
38
+
39
+ At a million players, materialize-daily by how often submissions actually improve:
40
+
41
+ | improving submissions | materialize daily |
42
+ |---:|---:|
43
+ | 100% (worst case, the table) | $940 |
44
+ | 50% | ~$580 |
45
+ | 20% (typical) | **~$220** |
46
+
47
+ That guard is the single biggest cost lever in the capability, and it is on by default. Its only cost is that `submittedAt` then tracks a player's last *improving* submission rather than their last submission of any kind — nothing in ranking depends on it. Set `trackActivity: true` on a board to write on every submission and keep `submittedAt` a true last-seen timestamp, at full write cost. `sum` and `latest` boards always write, since every submission changes the score.
48
+
49
+ ## The write model, measured
50
+
51
+ The write figures rest on one number: rows written per submission per window. It is **measured, not guessed** — `src/entry/writeAmplification.workers.test.ts` reads D1's own `meta.rows_written` for the real upsert and fails if it moves:
52
+
53
+ - A steady-state improving submission writes **2** rows: the entry row and the rank index. The unique-player index doesn't change on an update, so it costs nothing.
54
+ - The first-ever submission for a player writes **3** (both indexes), amortized to almost nothing across their submissions.
55
+ - A guarded non-improving submission writes **0**.
56
+
57
+ The entries table uses a plain `INTEGER PRIMARY KEY`, not `AUTOINCREMENT`, on purpose: autoincrement would add a `sqlite_sequence` write to *every* upsert — even a guarded no-op, because SQLite reserves the sequence value before it detects the conflict — turning the free no-op back into a billed one. A one-line schema choice keeps the lever sharp.
58
+
59
+ ## The two walls sit in different places
60
+
61
+ **Cost says materialize past ~100k. Throughput says shard past ~1M.**
62
+
63
+ Throughput is the harder wall, and the one the cost column hides. D1 executes one query at a time per database, and its docs describe a write as taking "several milliseconds" — which implies a practical ceiling somewhere near **200 upserts/sec**. At a million players the *average* is already 174/sec, about 87% of it, and any peak goes over. At ten million it is more than eight times over.
64
+
65
+ So **one D1 database tops out around a million players regardless of what it costs.** Cloudflare's endorsed answer past that is horizontal scale-out: shard into smaller per-tenant databases. The 10 GB per-database cap **cannot be increased**, which is the same message from the other direction.
66
+
67
+ ## Assumptions
68
+
69
+ Change any of these and the table moves.
70
+
71
+ - 5 submissions and 5 rank checks per player per day.
72
+ - 3 windows per board set (say daily + weekly + all-time).
73
+ - A 30-day month.
74
+ - 2 rows written per *writing* submission per window — the entry row and the rank index — measured, not assumed (see below). The published table assumes every submission writes; the default guarded board writes only for the minority that improve.
75
+ - A live rank check scans half the board (the average mid-table player).
76
+ - ~100 bytes per row.
77
+
78
+ ## What is cited and what is ours
79
+
80
+ **Cloudflare's, cited:** the unit prices (rows read 25B/mo included then $0.001/M; rows written 50M/mo then $1.00/M; storage 5 GB then $0.75/GB-mo), that billing counts rows *scanned* rather than returned, that D1 is single-threaded, and the 10 GB per-database cap.
81
+
82
+ **Ours, inferred — do not quote these as Cloudflare figures:**
83
+
84
+ - The half-board scan depth for a live rank check, the submission/check rates, and the window count.
85
+ - The **~200 upserts/sec ceiling**. Cloudflare publishes **no** writes/sec ceiling. This is our arithmetic on "several milliseconds" per write against a single thread.
86
+ - The **improve rate** — how many submissions actually beat a player's best and therefore write. The table's worst case assumes 100%; a real board is far lower. The *per-write* cost, by contrast, is measured against D1's own `meta.rows_written` (2 rows for a writing submission, 0 for a guarded no-op), not inferred.
87
+
88
+ Cloudflare also publishes **no cap on rows read**, which is worth sitting with: an un-materialized rank query on a large board runs the bill up without ever erroring. Nothing fails. The invoice just arrives.
89
+
90
+ Cloudflare documents **no rank-materialization pattern** either. The chunked refresh in this package is entirely adopter-built engineering — which is precisely why it is in the package instead of in your repo.
91
+
92
+ ## A note on window functions
93
+
94
+ `RANK() OVER` and `ROW_NUMBER() OVER` are **undocumented** on D1: Cloudflare's SQL reference neither supports nor denies them.
95
+
96
+ We probed it. Both execute under Miniflare's local D1. But Miniflare also rejects `sqlite_version()` with `not authorized to use function`, which proves D1 runs a function authorizer whose production allowlist is not visible from local — so a local pass is not evidence about production.
97
+
98
+ Ranking therefore does **not** use window functions. It uses `COUNT(*)` with an explicit predicate and a total ordering, which is documented SQL everywhere it runs. `src/rank/plan.workers.test.ts` reads D1's own `EXPLAIN QUERY PLAN` to prove the ranking index is actually chosen, because an index that silently stops being used is a cost regression, not a test failure — the answer stays right and the bill goes up.
99
+
100
+ ## This page will drift
101
+
102
+ Re-run the model on each release: `bun run --filter @pithy-sh/leaderboard costs`. The arithmetic is committed as a script (`scripts/costModel.ts`), not hand-maths in this prose, and `scripts/costModel.test.ts` pins every figure above. If a price or an assumption moves, those tests fail — and this page is wrong until both are updated together.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@pithy-sh/leaderboard",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/pithy-sh/pithy.git",
8
+ "directory": "packages/leaderboard"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "pithy.manifest.json",
13
+ "docs",
14
+ "!src/**/*.test.*"
15
+ ],
16
+ "type": "module",
17
+ "engines": {
18
+ "node": ">=22"
19
+ },
20
+ "exports": {
21
+ "./src/*": "./src/*.ts"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.json --noEmit false --outDir dist",
25
+ "typecheck": "tsc -p tsconfig.json",
26
+ "test": "vitest run",
27
+ "test:node": "vitest run --project=node",
28
+ "test:workers": "vitest run --project=workers",
29
+ "costs": "bun scripts/costModel.ts",
30
+ "clean": "rm -rf dist .turbo",
31
+ "reset": "bun run clean && rm -rf node_modules"
32
+ },
33
+ "dependencies": {
34
+ "@cloudflare/workers-types": "^5.20260729.1",
35
+ "@hono/zod-validator": "^0.9.0",
36
+ "@pithy-sh/core": "workspace:*",
37
+ "croner": "^10.0.1",
38
+ "hono": "^4.13.2",
39
+ "kysely": "^0.29.0",
40
+ "zod": "^4.0.0"
41
+ },
42
+ "devDependencies": {
43
+ "@cloudflare/vitest-plugin": "^1.0.0",
44
+ "@pithy-sh/tsconfig": "workspace:*",
45
+ "@types/node": "^22.15.0",
46
+ "@vitest/coverage-v8": "^4.1.0",
47
+ "kysely-d1": "^0.4.0",
48
+ "typescript": "^7.0.2",
49
+ "vitest": "^4.1.0",
50
+ "wrangler": "^4.115.0"
51
+ }
52
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "leaderboard",
3
+ "package": "@pithy-sh/leaderboard",
4
+ "requiredBindings": [{ "type": "d1", "name": "DB" }],
5
+ "peerCapabilities": [],
6
+ "optionalCapabilities": ["auth"],
7
+ "migrationNamespace": "leaderboard",
8
+ "whenToEnable": "Rank your players. Submit a score, read the standings, read your own rank — daily, weekly, calendar-month, calendar-year, or all-time, because a board's window is a CRON expression rather than a fixed list. Boards are config, not database rows. Everything lives in your own D1: closed windows stay for as many windows as you ask for, in plain SQL you can join against your own tables — no platform SDK offers either. One board serves iOS, Android, and web, which is why writes are server-authoritative by default: submissions need the board's submit scope, so a player's device cannot post a score it invented. Entries bind to an authenticated user, so add auth too — without it every route is denied. Read docs/costs.md before you pick a `rank` mode: live rank is correct and free under ~10k players and grows quadratically after that.",
9
+ "scaffold": [
10
+ "Add a `leaderboard({ boards: [...] })` block to pithy.config.ts and define at least one board.",
11
+ "Give each board a `key`, a `direction` (`desc` = highest wins, `asc` = lowest wins), and optionally a `window` CRON (`0 0 * * *` daily, `0 0 1 * *` calendar month). Omit `window` for an all-time board.",
12
+ "Bind a D1 database named DB in wrangler.jsonc — the same app database your other capabilities use.",
13
+ "Run `pithy migrate` to create pithy_leaderboard_entries and pithy_leaderboard_boards.",
14
+ "Add `@pithy-sh/auth` if it is not already installed. Leaderboard entries bind to an authenticated user; without auth every route is denied.",
15
+ "Mint a token carrying the `leaderboard:submit` scope for your trusted server, and submit scores with it. Never put that scope on a player's token.",
16
+ "Deploy the rank worker (a cron-triggered Workflow) if `rank` is `{ materialize }` or any board sets `retain`/`retainDays`. It can be its own worker or folded into your app worker — it just needs a cron trigger. A live board set that keeps everything (the default) needs no worker.",
17
+ "Read packages/leaderboard/docs/costs.md before going to production. The `rank` mode you pick at 1k players decides whether 1M costs $1,030 or $75,825."
18
+ ],
19
+ "configOptions": [
20
+ {
21
+ "key": "boards",
22
+ "default": [{ "key": "high-scores", "direction": "desc" }],
23
+ "describe": "Every board this app ranks. Replace this example — an all-time board where the highest score wins, which is the smallest board that works and the one shape that needs no rank worker at all. `key` is a URL path segment; `direction` is `desc` (highest wins) or `asc` (lowest wins) and is immutable once entries land, because flipping it would reinterpret every stored score. Add a `window` CRON (`0 0 * * *` daily, `0 0 1 * *` calendar month) to make the board periodic. At least one board is required."
24
+ },
25
+ {
26
+ "key": "rank",
27
+ "default": "live",
28
+ "describe": "How rank is computed: `live` (default — counted per request, always correct, $0 under ~10k players, quadratic past that) or `{ materialize: '<cron>' }` (a stored rank column refreshed on a schedule, the documented path past ~100k players). A player's own score stays live in both modes, which hides most of the staleness."
29
+ },
30
+ {
31
+ "key": "serverAuthoritative",
32
+ "default": true,
33
+ "describe": "Require the `leaderboard:submit` scope to post a score. On by default, inverting the vendor norm — every platform that offers server-authoritative writes ships it off. Turn it off only if you accept that a player's device can post any score it likes."
34
+ }
35
+ ]
36
+ }
@@ -0,0 +1,94 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { LeaderboardBoard } from "../config/config";
5
+ import { LeaderboardBoardRecord } from "../data/boardRecord";
6
+ import { LEADERBOARD_BOARDS_TABLE, type LeaderboardDatabase } from "../data/tables";
7
+ import { LeaderboardBoardImmutableError } from "../error/errors";
8
+
9
+ /**
10
+ * The board drift guard.
11
+ *
12
+ * `store`, `direction`, `aggregation`, and `window` are create-time and immutable on every vendor
13
+ * surveyed, and for good reason: each one is the lens through which stored scores are read, so changing
14
+ * one reinterprets data rather than reconfiguring behavior. Flip `direction` and last place becomes
15
+ * first. Switch `best` to `sum` and the number in the column stops meaning what it meant. Change `window`
16
+ * and new scores key into windows that do not line up with the stored ones. Move a board to a different
17
+ * `store` and its scores live in a different place entirely.
18
+ *
19
+ * Pithy cannot enforce that in the type system, because a board is config the adopter edits freely and
20
+ * redeploys. So the first entry on a board records those fields, and every later write checks against the
21
+ * record. The failure is loud and immediate instead of a silently corrupted board.
22
+ *
23
+ * This is the answer to the issue's open question: a board definition is *not* migratable. There is no
24
+ * `pithy migrate` story for a board, because there is no safe automatic reinterpretation of the scores
25
+ * already stored. Changing one of these fields means a new board key.
26
+ */
27
+ export async function assertBoardDefinition(
28
+ db: LeaderboardDatabase,
29
+ board: LeaderboardBoard,
30
+ now: Date,
31
+ ): Promise<void> {
32
+ let recorded = await db
33
+ .selectFrom(LEADERBOARD_BOARDS_TABLE)
34
+ .selectAll()
35
+ .where("boardKey", "=", board.key)
36
+ .executeTakeFirst();
37
+
38
+ if (!recorded) {
39
+ const row = LeaderboardBoardRecord.encode({
40
+ id: 0,
41
+ boardKey: board.key,
42
+ store: board.store,
43
+ direction: board.direction,
44
+ aggregation: board.aggregation,
45
+ window: board.window ?? null,
46
+ createdAt: now,
47
+ }) as Record<string, unknown>;
48
+ delete row.id;
49
+ // `OR IGNORE`, not a plain insert: two first-ever submissions can race here, and losing that race is
50
+ // not an error on its own — but the winner may have recorded a *different* definition (a config
51
+ // change to an immutable field landed between the two requests, mid-deploy). So we do not return on
52
+ // the insert; we re-read the row that actually won and fall through to the drift check below. That
53
+ // closes the window where a divergent first submission would otherwise skip the guard entirely.
54
+ await db
55
+ .insertInto(LEADERBOARD_BOARDS_TABLE)
56
+ // biome-ignore lint/suspicious/noExplicitAny: the encoded row is the schema's `z.input` side.
57
+ .values(row as any)
58
+ .onConflict((oc) => oc.column("boardKey").doNothing())
59
+ .execute();
60
+ recorded = await db
61
+ .selectFrom(LEADERBOARD_BOARDS_TABLE)
62
+ .selectAll()
63
+ .where("boardKey", "=", board.key)
64
+ .executeTakeFirst();
65
+ if (!recorded) {
66
+ // The row we just inserted-or-ignored is gone — only possible if retention or an admin deleted the
67
+ // whole board between the insert and this read. Treat it as transient rather than corrupting data.
68
+ throw new LeaderboardBoardImmutableError({
69
+ message: "That board's definition could not be confirmed. Retry.",
70
+ detail: `Board "${board.key}" record vanished immediately after insert; a concurrent delete is the likely cause.`,
71
+ });
72
+ }
73
+ }
74
+
75
+ const previous = LeaderboardBoardRecord.parse(recorded);
76
+ const drifted: string[] = [];
77
+ if (previous.store !== board.store) {
78
+ drifted.push(`store ${previous.store} → ${board.store}`);
79
+ }
80
+ if (previous.direction !== board.direction) {
81
+ drifted.push(`direction ${previous.direction} → ${board.direction}`);
82
+ }
83
+ if (previous.aggregation !== board.aggregation) {
84
+ drifted.push(`aggregation ${previous.aggregation} → ${board.aggregation}`);
85
+ }
86
+ if ((previous.window ?? null) !== (board.window ?? null)) {
87
+ drifted.push(`window ${previous.window ?? "all-time"} → ${board.window ?? "all-time"}`);
88
+ }
89
+ if (drifted.length > 0) {
90
+ throw new LeaderboardBoardImmutableError({
91
+ detail: `Board "${board.key}" changed after recording entries: ${drifted.join(", ")}.`,
92
+ });
93
+ }
94
+ }
@@ -0,0 +1,93 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { BindingSpecInput } from "@pithy-sh/core/src/capability/bindings";
5
+ import { type Capability, defineCapability } from "@pithy-sh/core/src/capability/capability";
6
+ import type { Migration } from "kysely/migration";
7
+ import { LeaderboardConfig, type LeaderboardConfigInput, materializeSchedule } from "./config/config";
8
+ import { leaderboardTables } from "./data/tables";
9
+ import { registerLeaderboardRoutes } from "./http/routes";
10
+ import { leaderboard_0001_entries } from "./migrations/0001_entries";
11
+ import { leaderboardExampleSeed } from "./seeds/example";
12
+ import { PACKAGE_VERSION } from "./version.generated";
13
+
14
+ /**
15
+ * Where leaderboard's migrations sort in the app database. Unique per database; the registry composes
16
+ * keys like `0400_leaderboard_0001_entries`. Sits after media (300) and audit (250).
17
+ */
18
+ export const LEADERBOARD_MIGRATION_ORDER = 400;
19
+
20
+ export type LeaderboardOptions = LeaderboardConfigInput & {
21
+ /** Mount the routes somewhere other than `/leaderboard`. */
22
+ basePath?: string;
23
+ };
24
+
25
+ export interface LeaderboardCapability extends Capability {
26
+ leaderboardConfig: LeaderboardConfig;
27
+ }
28
+
29
+ /**
30
+ * The leaderboard capability: submit a score, read the standings, read your own rank — across windows.
31
+ *
32
+ * Fully optional. Config, migrations, routes, and bindings arrive only on `pithy add leaderboard`, and
33
+ * `pithy remove leaderboard` is the clean inverse.
34
+ *
35
+ * One store, always: D1. There is no engine flag, because Durable Objects do not fix what a leaderboard
36
+ * actually strains against. A DO bills rows exactly as D1 does, so a rank scan inside one costs the same;
37
+ * it adds request and duration billing D1 does not have; and each object is single-threaded with a soft
38
+ * 1,000 req/s ceiling and the same 10 GB cap, so it does not relieve hot-board serialization either.
39
+ * Scale is a cadence dial (`rank: { materialize }`), which is pure D1. A DO earns its place only when
40
+ * `live: true` ships — as a WebSocket push layer *over* D1, which is a latency play, not a store.
41
+ *
42
+ * `dependsOn` is deliberately empty. Auth is not a peer capability but a seam: the routes read
43
+ * `c.var.auth` through core's `AuthContext`, so without `@pithy-sh/auth` installed every route is denied
44
+ * rather than open. That is the right failure and it needs no dependency edge.
45
+ */
46
+ export function leaderboard(options: LeaderboardOptions = { boards: [] }): LeaderboardCapability {
47
+ const { basePath, ...configInput } = options;
48
+ // Parses the board set, and validates every CRON at assembly — a typo fails on deploy, not on the
49
+ // first submission at 3am.
50
+ const resolved = LeaderboardConfig.parse(configInput);
51
+
52
+ const migrations: Record<string, Migration> = { "0001_entries": leaderboard_0001_entries };
53
+
54
+ const requiredBindings: BindingSpecInput[] = [{ type: "d1", name: "DB" }];
55
+
56
+ const capability = defineCapability({
57
+ name: "leaderboard",
58
+ // The package version this capability ships at, stamped by `scripts/stampVersions.ts` — a Worker
59
+ // cannot read its own package.json. Reported per capability by the control-plane manifest.
60
+ version: PACKAGE_VERSION,
61
+ requiredBindings,
62
+ config: LeaderboardConfig,
63
+ databases: {
64
+ app: {
65
+ binding: "DB",
66
+ tables: leaderboardTables(),
67
+ migrationOrder: LEADERBOARD_MIGRATION_ORDER,
68
+ migrations,
69
+ },
70
+ },
71
+ routes: registerLeaderboardRoutes({ config: resolved, basePath }),
72
+ seeds: [leaderboardExampleSeed],
73
+ });
74
+
75
+ return Object.assign(capability, { leaderboardConfig: resolved });
76
+ }
77
+
78
+ export function isLeaderboardCapability(capability: Capability): capability is LeaderboardCapability {
79
+ return capability.name === "leaderboard" && "leaderboardConfig" in capability;
80
+ }
81
+
82
+ /**
83
+ * Whether this configuration needs the rank worker deployed.
84
+ *
85
+ * Two things need it: materialized rank (the refresh), and configured retention (the prune). Retention
86
+ * now defaults to keep-all, so a windowed board only needs the worker if it actually sets `retain` or
87
+ * `retainDays` — a board that keeps everything has nothing to prune. A live, keep-all board set needs no
88
+ * worker at all.
89
+ */
90
+ export function needsRankWorker(config: LeaderboardConfig): boolean {
91
+ if (materializeSchedule(config) !== undefined) return true;
92
+ return config.boards.some((board) => board.retain !== undefined || board.retainDays !== undefined);
93
+ }
@@ -0,0 +1,14 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /// <reference types="@cloudflare/vitest-plugin/types" />
5
+
6
+ // Bindings the Workers-runtime test project provides to `*.workers.test.ts`, matching the Miniflare
7
+ // config in `vitest.workers.config.ts`: the app `DB` database the `pithy_leaderboard_*` tables live in.
8
+ // `cloudflare:test` types its `env` as `Cloudflare.Env`, so test bindings are declared by augmenting
9
+ // that interface.
10
+ declare namespace Cloudflare {
11
+ interface Env {
12
+ DB: D1Database;
13
+ }
14
+ }
@@ -0,0 +1,16 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * The board-key pattern, in the one module that has no reason to import anything (#430).
6
+ *
7
+ * Both `config/config.ts` and `http/schemas.ts` constrain a board key with this regex, and the schema
8
+ * used to take it from the config module. That module validates a board's cron window through
9
+ * `window/schedule.ts`, which imports `croner` — so a request schema, which a management client compiles
10
+ * in a browser to build a call, was dragging a cron parser in to spell one regex. `croner` runs in a
11
+ * browser perfectly well, and that is exactly why widening the allowlist would have been the wrong fix:
12
+ * the rule is what a browser build may reach, not what it can survive.
13
+ *
14
+ * A board key is a URL path segment (`/leaderboard/<key>/top`), so it is kebab-case and lowercase.
15
+ */
16
+ export const BOARD_KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/;