@pithy-sh/leaderboard 0.1.2 → 0.1.4

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.
Files changed (89) hide show
  1. package/dist/board/registry.d.ts +24 -0
  2. package/dist/board/registry.d.ts.map +1 -0
  3. package/dist/board/registry.js +54 -0
  4. package/dist/capability.d.ts +45 -0
  5. package/dist/capability.d.ts.map +1 -0
  6. package/dist/capability.js +75 -0
  7. package/dist/config/boardKey.d.ts +16 -0
  8. package/dist/config/boardKey.d.ts.map +1 -0
  9. package/dist/config/boardKey.js +18 -0
  10. package/dist/config/config.d.ts +98 -0
  11. package/dist/config/config.d.ts.map +1 -0
  12. package/dist/config/config.js +123 -0
  13. package/dist/data/boardRecord.d.ts +38 -0
  14. package/dist/data/boardRecord.d.ts.map +1 -0
  15. package/dist/data/boardRecord.js +33 -0
  16. package/dist/data/entry.d.ts +31 -0
  17. package/dist/data/entry.d.ts.map +1 -0
  18. package/dist/data/entry.js +32 -0
  19. package/dist/data/lock.d.ts +20 -0
  20. package/dist/data/lock.d.ts.map +1 -0
  21. package/dist/data/lock.js +21 -0
  22. package/dist/data/tables.d.ts +27 -0
  23. package/dist/data/tables.d.ts.map +1 -0
  24. package/dist/data/tables.js +31 -0
  25. package/dist/entry/store.d.ts +42 -0
  26. package/dist/entry/store.d.ts.map +1 -0
  27. package/dist/entry/store.js +86 -0
  28. package/dist/error/errors.d.ts +56 -0
  29. package/dist/error/errors.d.ts.map +1 -0
  30. package/dist/error/errors.js +78 -0
  31. package/dist/http/guard.d.ts +26 -0
  32. package/dist/http/guard.d.ts.map +1 -0
  33. package/dist/http/guard.js +50 -0
  34. package/dist/http/handlers.d.ts +81 -0
  35. package/dist/http/handlers.d.ts.map +1 -0
  36. package/dist/http/handlers.js +123 -0
  37. package/dist/http/routes.d.ts +48 -0
  38. package/dist/http/routes.d.ts.map +1 -0
  39. package/dist/http/routes.js +62 -0
  40. package/dist/http/schemas.d.ts +47 -0
  41. package/dist/http/schemas.d.ts.map +1 -0
  42. package/dist/http/schemas.js +38 -0
  43. package/dist/index.d.ts +18 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +10 -0
  46. package/dist/migrations/0001_entries.d.ts +10 -0
  47. package/dist/migrations/0001_entries.d.ts.map +1 -0
  48. package/dist/migrations/0001_entries.js +35 -0
  49. package/dist/rank/lock.d.ts +39 -0
  50. package/dist/rank/lock.d.ts.map +1 -0
  51. package/dist/rank/lock.js +56 -0
  52. package/dist/rank/materialize.d.ts +97 -0
  53. package/dist/rank/materialize.d.ts.map +1 -0
  54. package/dist/rank/materialize.js +142 -0
  55. package/dist/rank/query.d.ts +52 -0
  56. package/dist/rank/query.d.ts.map +1 -0
  57. package/dist/rank/query.js +142 -0
  58. package/dist/rank/retryPolicy.d.ts +35 -0
  59. package/dist/rank/retryPolicy.d.ts.map +1 -0
  60. package/dist/rank/retryPolicy.js +39 -0
  61. package/dist/rank/segment.d.ts +35 -0
  62. package/dist/rank/segment.d.ts.map +1 -0
  63. package/dist/rank/segment.js +37 -0
  64. package/dist/rank/tiers.d.ts +15 -0
  65. package/dist/rank/tiers.d.ts.map +1 -0
  66. package/dist/rank/tiers.js +24 -0
  67. package/dist/rank/worker.d.ts +61 -0
  68. package/dist/rank/worker.d.ts.map +1 -0
  69. package/dist/rank/worker.entry.d.ts +59 -0
  70. package/dist/rank/worker.entry.d.ts.map +1 -0
  71. package/dist/rank/worker.entry.js +65 -0
  72. package/dist/rank/worker.js +54 -0
  73. package/dist/retention/prune.d.ts +41 -0
  74. package/dist/retention/prune.d.ts.map +1 -0
  75. package/dist/retention/prune.js +67 -0
  76. package/dist/seeds/example.d.ts +13 -0
  77. package/dist/seeds/example.d.ts.map +1 -0
  78. package/dist/seeds/example.js +69 -0
  79. package/dist/session/bookmark.d.ts +49 -0
  80. package/dist/session/bookmark.d.ts.map +1 -0
  81. package/dist/session/bookmark.js +43 -0
  82. package/dist/version.generated.d.ts +7 -0
  83. package/dist/version.generated.d.ts.map +1 -0
  84. package/dist/version.generated.js +9 -0
  85. package/dist/window/schedule.d.ts +31 -0
  86. package/dist/window/schedule.d.ts.map +1 -0
  87. package/dist/window/schedule.js +108 -0
  88. package/package.json +21 -10
  89. package/src/version.generated.ts +1 -1
@@ -0,0 +1,142 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { LEADERBOARD_ENTRIES_TABLE } from "../data/tables.js";
4
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
5
+ import { sql } from "kysely";
6
+ import { boundParameterBudget, chunkRowsByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
7
+ //#region src/rank/materialize.ts
8
+ /**
9
+ * The rank refresh pass — what `rank: { materialize }` buys and what it costs.
10
+ *
11
+ * A full-table rank rewrite is the obvious implementation and the wrong one. D1 executes one query at a
12
+ * time per database and caps a query at 30 seconds; a single `UPDATE` over a large board would hold the
13
+ * only thread for its whole duration and risk the documented `overloaded` error for every live
14
+ * submission behind it. So the pass is chunked: many small, bounded statements the runtime can
15
+ * interleave with real traffic.
16
+ *
17
+ * A chunk is a **pacing** unit: how much of the board one keyset step walks, kept well inside D1's
18
+ * 30-second per-query limit. It is no longer also the width of a statement — the bulk update sizes
19
+ * itself against D1's bound-parameter cap through core's arithmetic, so a chunk of any size is written
20
+ * in as many statements as it takes. That separation is the fix for #250: `chunkSize` was unvalidated,
21
+ * and `chunkSize: 40` bound 120 and broke the pass with no warning that a limit was even involved.
22
+ *
23
+ * Chunks walk the board by keyset, not by `OFFSET`: an offset page makes SQLite count past every row it
24
+ * skips, so an offset walk is quadratic in billed rows — the very cost `materialize` exists to avoid.
25
+ *
26
+ * Cloudflare documents no rank-materialization pattern. All of this is adopter-built, which is exactly
27
+ * why it lives in the package instead of in every adopter's repo.
28
+ */
29
+ /**
30
+ * What one ranked row costs the bulk update: `WHEN id`, `THEN rank`, and the id again in the `IN` list.
31
+ *
32
+ * The cap itself is not restated here. It was, and a second copy of a platform limit is how a limit goes
33
+ * stale in one place and not the other — `MAX_BOUND_PARAMETERS` lives in `@pithy-sh/core`, once.
34
+ */
35
+ const RANK_PARAMETERS_PER_ROW = 3;
36
+ /**
37
+ * Rows per chunk by default: as many as one update statement can carry, from core's budget.
38
+ *
39
+ * Derived rather than written out, so it moves if the platform does. Any other size works — the update
40
+ * chunks itself — and this is simply the size at which a chunk is exactly one statement.
41
+ */
42
+ const RANK_CHUNK_SIZE = Math.floor(boundParameterBudget(0) / 3);
43
+ /**
44
+ * Chunks a refresh ranks before it checkpoints its cursor. `2000 * 33` is ~66k entries per Workflow step.
45
+ *
46
+ * The batch cap is stated here rather than in `worker.entry.ts`, which is the module that passes it as
47
+ * `maxChunks`. That module imports `cloudflare:workers`, so anything it exports is unreachable from a
48
+ * plain Node process, and a constant that reads as ordinary is exactly how #172 and #180 happened twice:
49
+ * a Node-side caller imports the number, gets workerd behind it, and the failure surfaces as
50
+ * `Could not load pithy.config.ts` — naming the config rather than the import. A pure value belongs in a
51
+ * pure module. `configEntrypoints.test.ts` is what holds that.
52
+ */
53
+ const REFRESH_BATCH_CHUNKS = 2e3;
54
+ /**
55
+ * Narrow a selected `achievedAt` to its stored epoch.
56
+ *
57
+ * The column's decode-side input is a union (`number | string | Date`) so the codec stays
58
+ * encode-compatible, but a chunk selects raw columns rather than parsing whole rows — D1 always hands
59
+ * back the stored integer. This keeps the keyset arithmetic honest without paying to parse every row.
60
+ */
61
+ function toEpoch(value) {
62
+ return value instanceof Date ? value.getTime() : Number(value);
63
+ }
64
+ /**
65
+ * Recompute the stored rank for one board and window, best first.
66
+ *
67
+ * Ranks are positions in the total ordering, so a chunk needs no counting — the first chunk's first row
68
+ * is rank 1 and every chunk continues the count. The pass is **resumable**: with no `maxChunks` it ranks
69
+ * the whole board; with a `maxChunks` batch cap it ranks that many chunks, returns a `cursor`, and the
70
+ * caller feeds the cursor (and `startRank: ranked`) back to continue. That is what lets a board of any
71
+ * size be ranked across a series of bounded, individually-durable Workflow steps — there is no longer a
72
+ * per-invocation ceiling on how many entries a board can have.
73
+ */
74
+ async function refreshWindowRanks(db, board, windowKey, options = {}) {
75
+ const chunkSize = options.chunkSize ?? RANK_CHUNK_SIZE;
76
+ if (!Number.isInteger(chunkSize) || chunkSize < 1) throw new ValidationError({
77
+ message: "The rank refresh was asked for an impossible chunk size.",
78
+ action: "Pass a whole number of one or more, or omit chunkSize for the default.",
79
+ detail: `refreshWindowRanks received chunkSize=${chunkSize}; it must be a positive integer.`
80
+ });
81
+ const maxChunks = options.maxChunks ?? Number.POSITIVE_INFINITY;
82
+ const descending = board.direction === "desc";
83
+ let after = options.resumeAfter;
84
+ let ranked = options.startRank ?? 0;
85
+ let chunks = 0;
86
+ while (chunks < maxChunks) {
87
+ let query = db.selectFrom(LEADERBOARD_ENTRIES_TABLE).select([
88
+ "id",
89
+ "score",
90
+ "achievedAt",
91
+ "userId"
92
+ ]).where("boardId", "=", board.key).where("windowKey", "=", windowKey).where("visible", "=", 1).where("hidden", "=", 0);
93
+ if (after) {
94
+ const scoreWorse = descending ? sql`score < ${after.score}` : sql`score > ${after.score}`;
95
+ query = query.where(sql`(
96
+ ${scoreWorse}
97
+ OR (score = ${after.score} AND achieved_at > ${after.achievedAt})
98
+ OR (score = ${after.score} AND achieved_at = ${after.achievedAt} AND user_id > ${after.userId})
99
+ )`);
100
+ }
101
+ const rows = await query.orderBy("score", descending ? "desc" : "asc").orderBy("achievedAt", "asc").orderBy("userId", "asc").limit(chunkSize).execute();
102
+ if (rows.length === 0) return {
103
+ ranked,
104
+ chunks,
105
+ complete: true,
106
+ cursor: null
107
+ };
108
+ let written = 0;
109
+ for (const group of chunkRowsByBoundParameters(rows, 3)) {
110
+ const base = ranked + written;
111
+ let cases = sql``;
112
+ group.forEach((row, index) => {
113
+ cases = sql`${cases} WHEN ${row.id} THEN ${base + index + 1}`;
114
+ });
115
+ await db.updateTable(LEADERBOARD_ENTRIES_TABLE).set({ rank: sql`CASE id ${cases} END` }).where("id", "in", group.map((row) => row.id)).execute();
116
+ written += group.length;
117
+ }
118
+ ranked += rows.length;
119
+ chunks += 1;
120
+ const last = rows[rows.length - 1];
121
+ if (!last) break;
122
+ after = {
123
+ score: last.score,
124
+ achievedAt: toEpoch(last.achievedAt),
125
+ userId: last.userId
126
+ };
127
+ if (rows.length < chunkSize) return {
128
+ ranked,
129
+ chunks,
130
+ complete: true,
131
+ cursor: null
132
+ };
133
+ }
134
+ return {
135
+ ranked,
136
+ chunks,
137
+ complete: false,
138
+ cursor: after ?? null
139
+ };
140
+ }
141
+ //#endregion
142
+ export { RANK_CHUNK_SIZE, RANK_PARAMETERS_PER_ROW, REFRESH_BATCH_CHUNKS, refreshWindowRanks };
@@ -0,0 +1,52 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { LeaderboardBoard } from "../config/config";
4
+ import { LeaderboardEntry } from "../data/entry";
5
+ import { type LeaderboardDatabase } from "../data/tables";
6
+ export interface RankedEntry {
7
+ userId: string;
8
+ score: number;
9
+ achievedAt: Date;
10
+ rank: number;
11
+ tier: string | null;
12
+ }
13
+ export interface ReadOptions {
14
+ /** Restrict the board to these players — a friends or cohort view over the same store, not a second board. */
15
+ segment?: readonly string[];
16
+ }
17
+ /**
18
+ * A page of the board, best first. Ranks are numbered from the offset: the ordering is total, so row
19
+ * `n` of an offset page is rank `offset + n + 1` by construction — no count, no window function.
20
+ */
21
+ export declare function topEntries(db: LeaderboardDatabase, board: LeaderboardBoard, windowKey: string, limit: number, offset?: number, options?: ReadOptions): Promise<RankedEntry[]>;
22
+ /**
23
+ * The player's own rank, or null if they have no visible entry.
24
+ *
25
+ * When `materialized` is set the stored rank column is read — one indexed point read, and the reason
26
+ * `rank: { materialize }` exists. Otherwise the rank is counted live: correct always, free under ~10k
27
+ * players, and O(rank position) in billed rows because D1 bills rows *scanned*, not returned. A stale
28
+ * materialized rank is deliberate; the player's own score beside it is always live.
29
+ */
30
+ export declare function rankOf(db: LeaderboardDatabase, board: LeaderboardBoard, windowKey: string, userId: string, materialized: boolean, options?: ReadOptions): Promise<{
31
+ entry: LeaderboardEntry;
32
+ rank: number | null;
33
+ tier: string | null;
34
+ } | null>;
35
+ /**
36
+ * The live-rank count: how many visible entries beat this one. Exported so `plan.workers.test.ts` can
37
+ * compile the real query and read D1's own `EXPLAIN QUERY PLAN` for it — a plan test that hand-wrote the
38
+ * SQL would only prove that the hand-written SQL is indexed.
39
+ */
40
+ export declare function rankCountQuery(db: LeaderboardDatabase, board: LeaderboardBoard, windowKey: string, entry: Pick<LeaderboardEntry, "score" | "achievedAt" | "userId">, options?: ReadOptions): import("kysely").SelectQueryBuilder<import("@pithy-sh/core/src/data/db").DatabaseSchema<import("../data/tables").LeaderboardTables>, "pithyLeaderboardEntries", {
41
+ better: number;
42
+ }>;
43
+ /**
44
+ * The slice of the board centered on a player: `radius` entries either side of them.
45
+ *
46
+ * Built on the total ordering — find the player's rank, then page the board around it — so it needs no
47
+ * second index and no window function. Rank is always counted live here even when the board is
48
+ * materialized: an "around me" page whose neighbors came from a stale rank column would show players
49
+ * who are no longer next to you.
50
+ */
51
+ export declare function entriesAround(db: LeaderboardDatabase, board: LeaderboardBoard, windowKey: string, userId: string, radius: number, options?: ReadOptions): Promise<RankedEntry[]>;
52
+ //# sourceMappingURL=query.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"query.d.ts","sourceRoot":"","sources":["../../src/rank/query.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,EAA6B,KAAK,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AA2CrF,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,IAAI,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB;AAED,MAAM,WAAW,WAAW;IAC1B,8GAA8G;IAC9G,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7B;AA4BD;;;GAGG;AACH,wBAAsB,UAAU,CAC9B,EAAE,EAAE,mBAAmB,EACvB,KAAK,EAAE,gBAAgB,EACvB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,EACb,MAAM,SAAI,EACV,OAAO,GAAE,WAAgB,GACxB,OAAO,CAAC,WAAW,EAAE,CAAC,CAqBxB;AAED;;;;;;;GAOG;AACH,wBAAsB,MAAM,CAC1B,EAAE,EAAE,mBAAmB,EACvB,KAAK,EAAE,gBAAgB,EACvB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,OAAO,EACrB,OAAO,GAAE,WAAgB,GACxB,OAAO,CAAC;IAAE,KAAK,EAAE,gBAAgB,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,IAAI,CAAC,CAoBvF;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,EAAE,EAAE,mBAAmB,EACvB,KAAK,EAAE,gBAAgB,EACvB,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,IAAI,CAAC,gBAAgB,EAAE,OAAO,GAAG,YAAY,GAAG,QAAQ,CAAC,EAChE,OAAO,GAAE,WAAgB;;GAc1B;AAED;;;;;;;GAOG;AACH,wBAAsB,aAAa,CACjC,EAAE,EAAE,mBAAmB,EACvB,KAAK,EAAE,gBAAgB,EACvB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,WAAgB,GACxB,OAAO,CAAC,WAAW,EAAE,CAAC,CAKxB"}
@@ -0,0 +1,142 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { LeaderboardEntry } from "../data/entry.js";
4
+ import { LEADERBOARD_ENTRIES_TABLE } from "../data/tables.js";
5
+ import "./segment.js";
6
+ import { classifyTier } from "./tiers.js";
7
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
8
+ import { sql } from "kysely";
9
+ import { MAX_BOUND_PARAMETERS, boundParameterBudget } from "@pithy-sh/core/src/data/boundParameters";
10
+ //#region src/rank/query.ts
11
+ /**
12
+ * Ranked reads: top-N, my-rank, and around-me.
13
+ *
14
+ * Two things shape every query here.
15
+ *
16
+ * **No window functions.** `RANK() OVER` and `ROW_NUMBER() OVER` are undocumented on D1 — Cloudflare's
17
+ * SQL reference neither supports nor denies them. They do execute under Miniflare, but Miniflare also
18
+ * rejects `sqlite_version()` with `not authorized to use function`, which proves D1 runs a function
19
+ * authorizer whose production allowlist is not visible from local. A local pass is therefore not evidence
20
+ * about production, so ranking is built on plain `COUNT(*)` and `ORDER BY` instead. See docs/costs.md.
21
+ *
22
+ * **The ordering is total.** Score, then earliest `achievedAt`, then `userId`. Because no two entries can
23
+ * tie, a rank is exactly "how many entries beat you, plus one" — so dense-vs-competition ranking never
24
+ * arises and neither is implemented. It also means a top-N page can number its own rows from the offset
25
+ * rather than asking the database to rank them.
26
+ */
27
+ /**
28
+ * The members a segment query may bind, or a refusal naming the cap.
29
+ *
30
+ * The HTTP boundary refuses an oversized segment already, which is why this was never a live defect —
31
+ * and is exactly why it is worth fixing anyway. `topEntries` and `rankOf` are exported: an adopter
32
+ * calling them directly got no such refusal, and a 120-friend segment reached D1 with 124 parameters.
33
+ * A rule enforced at one of several entrances is not enforced (#250).
34
+ *
35
+ * A refusal rather than a silent truncation: a segment is a *set of people*, and quietly ranking 80 of
36
+ * somebody's 120 friends would be a wrong answer presented as a right one.
37
+ */
38
+ function segmentMembers(segment) {
39
+ if (segment.length > Math.min(80, boundParameterBudget(10))) throw new ValidationError({
40
+ message: `A segment is capped at 80 players.`,
41
+ action: `Rank at most 80 players at a time.`,
42
+ detail: `A segment of ${segment.length} exceeds the cap of 80; D1 accepts ${MAX_BOUND_PARAMETERS} bound parameters and a segment query spends 10 of them before a single member.`
43
+ });
44
+ return [...segment];
45
+ }
46
+ /**
47
+ * "Strictly better than this entry", in the board's direction.
48
+ *
49
+ * Spelled out as an OR-of-ANDs rather than a row-value comparison (`(score, achieved_at) > (?, ?)`)
50
+ * because the sort directions are mixed — score descends while achievedAt ascends — and SQLite's
51
+ * row-value shortcut only applies when every key sorts the same way.
52
+ */
53
+ function betterThan(board, score, achievedAt, userId) {
54
+ const scoreBeats = board.direction === "desc" ? sql`score > ${score}` : sql`score < ${score}`;
55
+ return sql`(
56
+ ${scoreBeats}
57
+ OR (score = ${score} AND achieved_at < ${achievedAt})
58
+ OR (score = ${score} AND achieved_at = ${achievedAt} AND user_id < ${userId})
59
+ )`;
60
+ }
61
+ function decorate(board, entry, rank) {
62
+ return {
63
+ userId: entry.userId,
64
+ score: entry.score,
65
+ achievedAt: entry.achievedAt,
66
+ rank,
67
+ tier: classifyTier(board.tiers, board.direction, entry.score)
68
+ };
69
+ }
70
+ /**
71
+ * A page of the board, best first. Ranks are numbered from the offset: the ordering is total, so row
72
+ * `n` of an offset page is rank `offset + n + 1` by construction — no count, no window function.
73
+ */
74
+ async function topEntries(db, board, windowKey, limit, offset = 0, options = {}) {
75
+ let query = db.selectFrom(LEADERBOARD_ENTRIES_TABLE).selectAll().where("boardId", "=", board.key).where("windowKey", "=", windowKey).where("visible", "=", 1).where("hidden", "=", 0);
76
+ if (options.segment) {
77
+ if (options.segment.length === 0) return [];
78
+ query = query.where("userId", "in", segmentMembers(options.segment));
79
+ }
80
+ return (await query.orderBy("score", board.direction === "desc" ? "desc" : "asc").orderBy("achievedAt", "asc").orderBy("userId", "asc").limit(limit).offset(offset).execute()).map((row, index) => decorate(board, LeaderboardEntry.parse(row), offset + index + 1));
81
+ }
82
+ /**
83
+ * The player's own rank, or null if they have no visible entry.
84
+ *
85
+ * When `materialized` is set the stored rank column is read — one indexed point read, and the reason
86
+ * `rank: { materialize }` exists. Otherwise the rank is counted live: correct always, free under ~10k
87
+ * players, and O(rank position) in billed rows because D1 bills rows *scanned*, not returned. A stale
88
+ * materialized rank is deliberate; the player's own score beside it is always live.
89
+ */
90
+ async function rankOf(db, board, windowKey, userId, materialized, options = {}) {
91
+ const row = await db.selectFrom(LEADERBOARD_ENTRIES_TABLE).selectAll().where("boardId", "=", board.key).where("windowKey", "=", windowKey).where("userId", "=", userId).executeTakeFirst();
92
+ if (!row) return null;
93
+ const entry = LeaderboardEntry.parse(row);
94
+ const tier = classifyTier(board.tiers, board.direction, entry.score);
95
+ if (!entry.visible || entry.hidden) return {
96
+ entry,
97
+ rank: null,
98
+ tier
99
+ };
100
+ if (materialized && !options.segment) return {
101
+ entry,
102
+ rank: entry.rank ?? null,
103
+ tier
104
+ };
105
+ if (options.segment?.length === 0) return {
106
+ entry,
107
+ rank: null,
108
+ tier
109
+ };
110
+ const counted = await rankCountQuery(db, board, windowKey, entry, options).executeTakeFirst();
111
+ return {
112
+ entry,
113
+ rank: Number(counted?.better ?? 0) + 1,
114
+ tier
115
+ };
116
+ }
117
+ /**
118
+ * The live-rank count: how many visible entries beat this one. Exported so `plan.workers.test.ts` can
119
+ * compile the real query and read D1's own `EXPLAIN QUERY PLAN` for it — a plan test that hand-wrote the
120
+ * SQL would only prove that the hand-written SQL is indexed.
121
+ */
122
+ function rankCountQuery(db, board, windowKey, entry, options = {}) {
123
+ let query = db.selectFrom(LEADERBOARD_ENTRIES_TABLE).select(({ fn }) => fn.countAll().as("better")).where("boardId", "=", board.key).where("windowKey", "=", windowKey).where("visible", "=", 1).where("hidden", "=", 0);
124
+ if (options.segment && options.segment.length > 0) query = query.where("userId", "in", segmentMembers(options.segment));
125
+ return query.where(betterThan(board, entry.score, entry.achievedAt.getTime(), entry.userId));
126
+ }
127
+ /**
128
+ * The slice of the board centered on a player: `radius` entries either side of them.
129
+ *
130
+ * Built on the total ordering — find the player's rank, then page the board around it — so it needs no
131
+ * second index and no window function. Rank is always counted live here even when the board is
132
+ * materialized: an "around me" page whose neighbors came from a stale rank column would show players
133
+ * who are no longer next to you.
134
+ */
135
+ async function entriesAround(db, board, windowKey, userId, radius, options = {}) {
136
+ const own = await rankOf(db, board, windowKey, userId, false, options);
137
+ if (!own || own.rank === null) return [];
138
+ const offset = Math.max(0, own.rank - radius - 1);
139
+ return topEntries(db, board, windowKey, radius * 2 + 1, offset, options);
140
+ }
141
+ //#endregion
142
+ export { entriesAround, rankCountQuery, rankOf, topEntries };
@@ -0,0 +1,35 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { WorkflowRetryPolicy } from "@pithy-sh/core/src/workflow/faults";
4
+ /**
5
+ * **What the rank refresh retries, and what it refuses to.**
6
+ *
7
+ * The answer is short because the refresh's world is: every step it runs — the journalled context, the
8
+ * prune, and one keyset page of ranking per step — talks to D1 and to nothing else. There is no
9
+ * provider, no bucket, no model, no second account. So there is no `leaderboard/*` code this pass can
10
+ * usefully re-drive, and the record is empty on purpose (pithy-sh/pithy#348).
11
+ *
12
+ * **An empty record is a statement, not an omission.** Core still answers for D1 through `withD1Retry`'s
13
+ * vocabulary — busy, timed out, connection lost, storage reset, internal — so a database under
14
+ * contention is re-driven with the step's much longer backoff, and nothing about that is restated here:
15
+ * one D1 vocabulary, in core, or the two drift. What the empty record adds is the other half — that
16
+ * leaderboard has looked at its own codes and retries none of them.
17
+ *
18
+ * ## Terminal, and why
19
+ *
20
+ * - **`leaderboard/invalid_schedule`** — a board's window CRON will not parse. It is config, it is
21
+ * identical on the next attempt, and it wants the adopter to edit `pithy.config.ts`.
22
+ * - **`validation/invalid_input`** — a board whose keyset cursor or rank shape the materializer refuses.
23
+ * Deterministic in the row it read.
24
+ * - **`leaderboard/board_not_found`, `leaderboard/board_immutable`** and the rest of the submit-path
25
+ * codes. They belong to a request; a refresh that somehow raised one has found a bug, and a bug
26
+ * surfaces faster than it backs off.
27
+ *
28
+ * **The cron is the outer retry, and that is why terminal is cheap here.** A refresh fires on a
29
+ * schedule, takes an advisory lock, and re-ranks from the top; a run that fails releases its lock in a
30
+ * `finally` and the next fire does the whole job again. So a fault that stops one run costs one
31
+ * interval, where five platform attempts against an answer that cannot change cost the interval *and*
32
+ * hold the lock through it — which is the one thing that makes the next fire skip too.
33
+ */
34
+ export declare const leaderboardWorkflowRetry: WorkflowRetryPolicy;
35
+ //# sourceMappingURL=retryPolicy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retryPolicy.d.ts","sourceRoot":"","sources":["../../src/rank/retryPolicy.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oCAAoC,CAAC;AAE9E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,eAAO,MAAM,wBAAwB,EAAE,mBAGtC,CAAC"}
@@ -0,0 +1,39 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/rank/retryPolicy.ts
4
+ /**
5
+ * **What the rank refresh retries, and what it refuses to.**
6
+ *
7
+ * The answer is short because the refresh's world is: every step it runs — the journalled context, the
8
+ * prune, and one keyset page of ranking per step — talks to D1 and to nothing else. There is no
9
+ * provider, no bucket, no model, no second account. So there is no `leaderboard/*` code this pass can
10
+ * usefully re-drive, and the record is empty on purpose (pithy-sh/pithy#348).
11
+ *
12
+ * **An empty record is a statement, not an omission.** Core still answers for D1 through `withD1Retry`'s
13
+ * vocabulary — busy, timed out, connection lost, storage reset, internal — so a database under
14
+ * contention is re-driven with the step's much longer backoff, and nothing about that is restated here:
15
+ * one D1 vocabulary, in core, or the two drift. What the empty record adds is the other half — that
16
+ * leaderboard has looked at its own codes and retries none of them.
17
+ *
18
+ * ## Terminal, and why
19
+ *
20
+ * - **`leaderboard/invalid_schedule`** — a board's window CRON will not parse. It is config, it is
21
+ * identical on the next attempt, and it wants the adopter to edit `pithy.config.ts`.
22
+ * - **`validation/invalid_input`** — a board whose keyset cursor or rank shape the materializer refuses.
23
+ * Deterministic in the row it read.
24
+ * - **`leaderboard/board_not_found`, `leaderboard/board_immutable`** and the rest of the submit-path
25
+ * codes. They belong to a request; a refresh that somehow raised one has found a bug, and a bug
26
+ * surfaces faster than it backs off.
27
+ *
28
+ * **The cron is the outer retry, and that is why terminal is cheap here.** A refresh fires on a
29
+ * schedule, takes an advisory lock, and re-ranks from the top; a run that fails releases its lock in a
30
+ * `finally` and the next fire does the whole job again. So a fault that stops one run costs one
31
+ * interval, where five platform attempts against an answer that cannot change cost the interval *and*
32
+ * hold the lock through it — which is the one thing that makes the next fire skip too.
33
+ */
34
+ const leaderboardWorkflowRetry = {
35
+ capability: "leaderboard",
36
+ retryable: {}
37
+ };
38
+ //#endregion
39
+ export { leaderboardWorkflowRetry };
@@ -0,0 +1,35 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * The segment cap, in the one module that has no reason to import anything (#430).
5
+ *
6
+ * `http/schemas.ts` states the same bound a caller is refused by, so it needs this number — and it used
7
+ * to reach it through `rank/query.ts`, which builds the SQL and therefore pulls Kysely, `kysely-d1` and
8
+ * `@cloudflare/workers-types` behind it. A request schema is a client's business: a management client
9
+ * building a call must be able to compile the shape it may send, in a browser, with no Worker types in
10
+ * reach. So the number moved and the query kept the query.
11
+ *
12
+ * **The relationship this number only means something against lives elsewhere, on purpose.**
13
+ * `boundParameterBudget` is in `@pithy-sh/core/src/data/boundParameters`, which imports `D1Database` for
14
+ * the guard beside it, so importing it here would put the data layer back under a browser program by a
15
+ * shorter route. `rank/query.workers.test.ts` asserts `MAX_SEGMENT_SIZE <=
16
+ * boundParameterBudget(SEGMENT_FIXED_PARAMETERS)` where the budget is already in scope, and that
17
+ * assertion is what ties these two constants to D1's ceiling. Moving them without it detaches the cap
18
+ * from the limit it exists for, which is #250 with no symptom until real data arrives.
19
+ */
20
+ /**
21
+ * What a segment query binds besides the members: 4 filters (boardId, windowKey, visible, hidden) plus
22
+ * the 6 of `betterThan` (score twice, achieved_at twice, score and userId again). `rankOf` within a
23
+ * segment is the tightest path, so its overhead is the one that sets the cap.
24
+ */
25
+ export declare const SEGMENT_FIXED_PARAMETERS = 10;
26
+ /**
27
+ * Cap on a segment's member count.
28
+ *
29
+ * `boundParameterBudget(SEGMENT_FIXED_PARAMETERS)` is 90 — the most D1 would take. 80 is deliberately
30
+ * inside it, so that adding one more filter to a segment query is a change to `rank/query.ts` rather
31
+ * than a change to what every caller may pass. `segmentMembers` asserts the relationship rather than
32
+ * trusting it, and `boundParameters.test.ts` in core owns the arithmetic itself.
33
+ */
34
+ export declare const MAX_SEGMENT_SIZE = 80;
35
+ //# sourceMappingURL=segment.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"segment.d.ts","sourceRoot":"","sources":["../../src/rank/segment.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;GAgBG;AAEH;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,KAAK,CAAC;AAE3C;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,KAAK,CAAC"}
@@ -0,0 +1,37 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/rank/segment.ts
4
+ /**
5
+ * The segment cap, in the one module that has no reason to import anything (#430).
6
+ *
7
+ * `http/schemas.ts` states the same bound a caller is refused by, so it needs this number — and it used
8
+ * to reach it through `rank/query.ts`, which builds the SQL and therefore pulls Kysely, `kysely-d1` and
9
+ * `@cloudflare/workers-types` behind it. A request schema is a client's business: a management client
10
+ * building a call must be able to compile the shape it may send, in a browser, with no Worker types in
11
+ * reach. So the number moved and the query kept the query.
12
+ *
13
+ * **The relationship this number only means something against lives elsewhere, on purpose.**
14
+ * `boundParameterBudget` is in `@pithy-sh/core/src/data/boundParameters`, which imports `D1Database` for
15
+ * the guard beside it, so importing it here would put the data layer back under a browser program by a
16
+ * shorter route. `rank/query.workers.test.ts` asserts `MAX_SEGMENT_SIZE <=
17
+ * boundParameterBudget(SEGMENT_FIXED_PARAMETERS)` where the budget is already in scope, and that
18
+ * assertion is what ties these two constants to D1's ceiling. Moving them without it detaches the cap
19
+ * from the limit it exists for, which is #250 with no symptom until real data arrives.
20
+ */
21
+ /**
22
+ * What a segment query binds besides the members: 4 filters (boardId, windowKey, visible, hidden) plus
23
+ * the 6 of `betterThan` (score twice, achieved_at twice, score and userId again). `rankOf` within a
24
+ * segment is the tightest path, so its overhead is the one that sets the cap.
25
+ */
26
+ const SEGMENT_FIXED_PARAMETERS = 10;
27
+ /**
28
+ * Cap on a segment's member count.
29
+ *
30
+ * `boundParameterBudget(SEGMENT_FIXED_PARAMETERS)` is 90 — the most D1 would take. 80 is deliberately
31
+ * inside it, so that adding one more filter to a segment query is a change to `rank/query.ts` rather
32
+ * than a change to what every caller may pass. `segmentMembers` asserts the relationship rather than
33
+ * trusting it, and `boundParameters.test.ts` in core owns the arithmetic itself.
34
+ */
35
+ const MAX_SEGMENT_SIZE = 80;
36
+ //#endregion
37
+ export { MAX_SEGMENT_SIZE, SEGMENT_FIXED_PARAMETERS };
@@ -0,0 +1,15 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { LeaderboardTier, ScoreDirection } from "../config/config";
4
+ /**
5
+ * Classify a score into one of the board's tiers, or null if it reaches none.
6
+ *
7
+ * Tiers are a read-side classification over the score already stored — no tier column, no write-side
8
+ * cost, and no second board type. Config validates that tiers are listed worst to best in the board's
9
+ * direction, so the last one the score qualifies for is the best one it qualifies for.
10
+ *
11
+ * This is not the bucketed-cohort model (Duolingo leagues, Clash Royale arenas), which needs durable
12
+ * per-window cohort assignment and provably cannot be a view over a global board. That is deferred.
13
+ */
14
+ export declare function classifyTier(tiers: readonly LeaderboardTier[] | undefined, direction: ScoreDirection, score: number): string | null;
15
+ //# sourceMappingURL=tiers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tiers.d.ts","sourceRoot":"","sources":["../../src/rank/tiers.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAExE;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,SAAS,eAAe,EAAE,GAAG,SAAS,EAC7C,SAAS,EAAE,cAAc,EACzB,KAAK,EAAE,MAAM,GACZ,MAAM,GAAG,IAAI,CASf"}
@@ -0,0 +1,24 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/rank/tiers.ts
4
+ /**
5
+ * Classify a score into one of the board's tiers, or null if it reaches none.
6
+ *
7
+ * Tiers are a read-side classification over the score already stored — no tier column, no write-side
8
+ * cost, and no second board type. Config validates that tiers are listed worst to best in the board's
9
+ * direction, so the last one the score qualifies for is the best one it qualifies for.
10
+ *
11
+ * This is not the bucketed-cohort model (Duolingo leagues, Clash Royale arenas), which needs durable
12
+ * per-window cohort assignment and provably cannot be a view over a global board. That is deferred.
13
+ */
14
+ function classifyTier(tiers, direction, score) {
15
+ if (!tiers || tiers.length === 0) return null;
16
+ let reached = null;
17
+ for (const tier of tiers) {
18
+ if (!(direction === "desc" ? score >= tier.from : score <= tier.from)) break;
19
+ reached = tier.key;
20
+ }
21
+ return reached;
22
+ }
23
+ //#endregion
24
+ export { classifyTier };
@@ -0,0 +1,61 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { D1Database } from "@cloudflare/workers-types";
4
+ import type { LeaderboardBoard, LeaderboardConfig } from "../config/config";
5
+ import { type PruneOutcome } from "../retention/prune";
6
+ import { type RefreshResult } from "./materialize";
7
+ /**
8
+ * The rank pass, in process: the retention sweep, then the rank refresh, each board ranked to completion.
9
+ *
10
+ * This is the reusable core the {@link RankRefreshWorkflow} drives step by step, and a standalone entry
11
+ * point for a caller who wants the whole pass in one call (tests, a simple scheduled handler). It runs
12
+ * every board to completion — there is no per-invocation chunk cap, so no board-size ceiling. The
13
+ * Workflow adds durability and per-board checkpointing on top of these same primitives; it does not need
14
+ * a different pass.
15
+ *
16
+ * Order matters. Pruning first means the refresh never spends a chunk ranking rows about to be deleted,
17
+ * and a board whose retention just dropped a window does not briefly publish ranks for it.
18
+ */
19
+ /**
20
+ * One board's contribution to a rank pass: what the refresh did, or that it could not be done (#371).
21
+ *
22
+ * The state rides on the value. A board that threw is not a board that ranked nobody — `ranked: 0` is a
23
+ * real answer about an empty window — so the numbers live behind `refreshed` and a consumer reaches them
24
+ * only by narrowing. Forgetting the sick board is a type error rather than a zero on a dashboard.
25
+ */
26
+ export type BoardRefresh = ({
27
+ /** This board's open window was ranked. */
28
+ state: "refreshed";
29
+ /** The board's key, as its config declares it. */
30
+ board: string;
31
+ /** The window key that was ranked. */
32
+ window: string;
33
+ } & RefreshResult) | {
34
+ /** This board's refresh threw. Its ranks are whatever the previous pass left. */
35
+ state: "unavailable";
36
+ /** The board's key, as its config declares it. */
37
+ board: string;
38
+ /** The window key the pass was attempting. */
39
+ window: string;
40
+ };
41
+ export interface RankPassResult {
42
+ /** What the retention sweep deleted, and whether it reached every board. */
43
+ pruned: PruneOutcome;
44
+ /** One entry per materialized board the refresh pass touched. Empty when rank is live. */
45
+ refreshed: BoardRefresh[];
46
+ }
47
+ /** The boards a refresh pass must rank: only materialized ones. Live boards compute rank per request. */
48
+ export declare function materializedBoards(config: LeaderboardConfig): LeaderboardBoard[];
49
+ /**
50
+ * **Every contributor here is degraded, and none of them is load-bearing (#371).**
51
+ *
52
+ * The sweep runs first so the refresh never spends a chunk ranking rows about to be deleted — an
53
+ * efficiency, not a precondition. A board whose prune throws keeps its old rows a while longer and ranks
54
+ * correctly regardless, so the sweep failing is no reason to leave every board's ranks stale. And boards
55
+ * are independent of each other by construction: one board's entries, windows and ranks are its own, so a
56
+ * board that will not rank has no claim on any other board's pass.
57
+ *
58
+ * So one sick board costs its own line. What it must never do is read as a board that ranked nobody.
59
+ */
60
+ export declare function runRankPass(d1: D1Database, config: LeaderboardConfig, now: Date): Promise<RankPassResult>;
61
+ //# sourceMappingURL=worker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../../src/rank/worker.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAC5D,OAAO,KAAK,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAG5E,OAAO,EAAE,KAAK,YAAY,EAAe,MAAM,oBAAoB,CAAC;AAEpE,OAAO,EAAE,KAAK,aAAa,EAAsB,MAAM,eAAe,CAAC;AAEvE;;;;;;;;;;;GAWG;AACH;;;;;;GAMG;AACH,MAAM,MAAM,YAAY,GACpB,CAAC;IACC,2CAA2C;IAC3C,KAAK,EAAE,WAAW,CAAC;IACnB,kDAAkD;IAClD,KAAK,EAAE,MAAM,CAAC;IACd,sCAAsC;IACtC,MAAM,EAAE,MAAM,CAAC;CAChB,GAAG,aAAa,CAAC,GAClB;IACE,iFAAiF;IACjF,KAAK,EAAE,aAAa,CAAC;IACrB,kDAAkD;IAClD,KAAK,EAAE,MAAM,CAAC;IACd,8CAA8C;IAC9C,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEN,MAAM,WAAW,cAAc;IAC7B,4EAA4E;IAC5E,MAAM,EAAE,YAAY,CAAC;IACrB,0FAA0F;IAC1F,SAAS,EAAE,YAAY,EAAE,CAAC;CAC3B;AAED,yGAAyG;AACzG,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,iBAAiB,GAAG,gBAAgB,EAAE,CAEhF;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,WAAW,CAAC,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,iBAAiB,EAAE,GAAG,EAAE,IAAI,GAAG,OAAO,CAAC,cAAc,CAAC,CAsB/G"}