@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.
- package/dist/board/registry.d.ts +24 -0
- package/dist/board/registry.d.ts.map +1 -0
- package/dist/board/registry.js +54 -0
- package/dist/capability.d.ts +45 -0
- package/dist/capability.d.ts.map +1 -0
- package/dist/capability.js +75 -0
- package/dist/config/boardKey.d.ts +16 -0
- package/dist/config/boardKey.d.ts.map +1 -0
- package/dist/config/boardKey.js +18 -0
- package/dist/config/config.d.ts +98 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +123 -0
- package/dist/data/boardRecord.d.ts +38 -0
- package/dist/data/boardRecord.d.ts.map +1 -0
- package/dist/data/boardRecord.js +33 -0
- package/dist/data/entry.d.ts +31 -0
- package/dist/data/entry.d.ts.map +1 -0
- package/dist/data/entry.js +32 -0
- package/dist/data/lock.d.ts +20 -0
- package/dist/data/lock.d.ts.map +1 -0
- package/dist/data/lock.js +21 -0
- package/dist/data/tables.d.ts +27 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/data/tables.js +31 -0
- package/dist/entry/store.d.ts +42 -0
- package/dist/entry/store.d.ts.map +1 -0
- package/dist/entry/store.js +86 -0
- package/dist/error/errors.d.ts +56 -0
- package/dist/error/errors.d.ts.map +1 -0
- package/dist/error/errors.js +78 -0
- package/dist/http/guard.d.ts +26 -0
- package/dist/http/guard.d.ts.map +1 -0
- package/dist/http/guard.js +50 -0
- package/dist/http/handlers.d.ts +81 -0
- package/dist/http/handlers.d.ts.map +1 -0
- package/dist/http/handlers.js +123 -0
- package/dist/http/routes.d.ts +48 -0
- package/dist/http/routes.d.ts.map +1 -0
- package/dist/http/routes.js +62 -0
- package/dist/http/schemas.d.ts +47 -0
- package/dist/http/schemas.d.ts.map +1 -0
- package/dist/http/schemas.js +38 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/migrations/0001_entries.d.ts +10 -0
- package/dist/migrations/0001_entries.d.ts.map +1 -0
- package/dist/migrations/0001_entries.js +35 -0
- package/dist/rank/lock.d.ts +39 -0
- package/dist/rank/lock.d.ts.map +1 -0
- package/dist/rank/lock.js +56 -0
- package/dist/rank/materialize.d.ts +97 -0
- package/dist/rank/materialize.d.ts.map +1 -0
- package/dist/rank/materialize.js +142 -0
- package/dist/rank/query.d.ts +52 -0
- package/dist/rank/query.d.ts.map +1 -0
- package/dist/rank/query.js +142 -0
- package/dist/rank/retryPolicy.d.ts +35 -0
- package/dist/rank/retryPolicy.d.ts.map +1 -0
- package/dist/rank/retryPolicy.js +39 -0
- package/dist/rank/segment.d.ts +35 -0
- package/dist/rank/segment.d.ts.map +1 -0
- package/dist/rank/segment.js +37 -0
- package/dist/rank/tiers.d.ts +15 -0
- package/dist/rank/tiers.d.ts.map +1 -0
- package/dist/rank/tiers.js +24 -0
- package/dist/rank/worker.d.ts +61 -0
- package/dist/rank/worker.d.ts.map +1 -0
- package/dist/rank/worker.entry.d.ts +59 -0
- package/dist/rank/worker.entry.d.ts.map +1 -0
- package/dist/rank/worker.entry.js +65 -0
- package/dist/rank/worker.js +54 -0
- package/dist/retention/prune.d.ts +41 -0
- package/dist/retention/prune.d.ts.map +1 -0
- package/dist/retention/prune.js +67 -0
- package/dist/seeds/example.d.ts +13 -0
- package/dist/seeds/example.d.ts.map +1 -0
- package/dist/seeds/example.js +69 -0
- package/dist/session/bookmark.d.ts +49 -0
- package/dist/session/bookmark.d.ts.map +1 -0
- package/dist/session/bookmark.js +43 -0
- package/dist/version.generated.d.ts +7 -0
- package/dist/version.generated.d.ts.map +1 -0
- package/dist/version.generated.js +9 -0
- package/dist/window/schedule.d.ts +31 -0
- package/dist/window/schedule.d.ts.map +1 -0
- package/dist/window/schedule.js +108 -0
- package/package.json +21 -10
- 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"}
|