@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,123 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { LeaderboardBoardNotFoundError, LeaderboardEntryNotFoundError, LeaderboardScoreRejectedError } from "../error/errors.js";
4
+ import { windowKeyAt } from "../window/schedule.js";
5
+ import { assertBoardDefinition } from "../board/registry.js";
6
+ import { entryStore } from "../entry/store.js";
7
+ import { entriesAround, rankOf, topEntries } from "../rank/query.js";
8
+ //#region src/http/handlers.ts
9
+ /** The configured board, or a 404. Board keys come from config, so an unknown key is not a lookup miss. */
10
+ function board(deps, key) {
11
+ const found = deps.config.boards.find((b) => b.key === key);
12
+ if (!found) throw new LeaderboardBoardNotFoundError({ detail: `No board configured with key "${key}".` });
13
+ return found;
14
+ }
15
+ /**
16
+ * Which window a read applies to: the one the client named, or the one open now.
17
+ *
18
+ * A named window is not validated against the schedule. A key that never was a window simply selects no
19
+ * entries, and letting a client read a closed window by key is the feature — that history is in the
20
+ * adopter's own D1, retained on their terms.
21
+ */
22
+ function readWindow(deps, b, requested) {
23
+ return requested ?? windowKeyAt(b.window, deps.now());
24
+ }
25
+ async function submitScore(deps, boardKey, body) {
26
+ const b = board(deps, boardKey);
27
+ const { score } = body;
28
+ if (b.min !== void 0 && score < b.min) throw new LeaderboardScoreRejectedError({ detail: `Score ${score} is below board "${b.key}" min ${b.min}.` });
29
+ if (b.max !== void 0 && score > b.max) throw new LeaderboardScoreRejectedError({ detail: `Score ${score} is above board "${b.key}" max ${b.max}.` });
30
+ const now = deps.now();
31
+ await assertBoardDefinition(deps.db, b, now);
32
+ const window = windowKeyAt(b.window, now);
33
+ await entryStore(deps.db).submit(b, window, deps.userId, score, now, deps.config.visibleByDefault);
34
+ return { window };
35
+ }
36
+ async function readTop(deps, boardKey, query) {
37
+ const b = board(deps, boardKey);
38
+ const window = readWindow(deps, b, query.window);
39
+ return {
40
+ board: b.key,
41
+ window,
42
+ entries: await topEntries(deps.db, b, window, query.limit, query.offset)
43
+ };
44
+ }
45
+ async function readSegment(deps, boardKey, body) {
46
+ const b = board(deps, boardKey);
47
+ const { userIds, limit, offset, window: requested } = body;
48
+ const window = readWindow(deps, b, requested);
49
+ return {
50
+ board: b.key,
51
+ window,
52
+ entries: await topEntries(deps.db, b, window, limit, offset, { segment: userIds })
53
+ };
54
+ }
55
+ async function readOwnRank(deps, boardKey, query) {
56
+ const b = board(deps, boardKey);
57
+ const window = readWindow(deps, b, query.window);
58
+ const materialized = typeof deps.config.rank === "object";
59
+ const own = await rankOf(deps.db, b, window, deps.userId, materialized);
60
+ if (!own) throw new LeaderboardEntryNotFoundError({ detail: `User ${deps.userId} has no entry on board "${b.key}" in window ${window}.` });
61
+ return {
62
+ board: b.key,
63
+ window,
64
+ userId: deps.userId,
65
+ score: own.entry.score,
66
+ rank: own.rank,
67
+ tier: own.tier,
68
+ visible: own.entry.visible,
69
+ rankStale: materialized
70
+ };
71
+ }
72
+ async function readAround(deps, boardKey, query) {
73
+ const b = board(deps, boardKey);
74
+ const window = readWindow(deps, b, query.window);
75
+ return {
76
+ board: b.key,
77
+ window,
78
+ entries: await entriesAround(deps.db, b, window, deps.userId, query.radius)
79
+ };
80
+ }
81
+ async function setOwnVisibility(deps, boardKey, query, body) {
82
+ const b = board(deps, boardKey);
83
+ const { visible } = body;
84
+ const window = readWindow(deps, b, query.window);
85
+ if (!await entryStore(deps.db).setVisibility(b.key, window, deps.userId, visible)) throw new LeaderboardEntryNotFoundError({ detail: `User ${deps.userId} has no entry on board "${b.key}" in window ${window} to make ${visible ? "visible" : "hidden"}.` });
86
+ return { visible };
87
+ }
88
+ async function hideEntry(deps, boardKey, targetUserId, query, body) {
89
+ const b = board(deps, boardKey);
90
+ const { hidden } = body;
91
+ const window = readWindow(deps, b, query.window);
92
+ if (!await entryStore(deps.db).hide(b.key, window, targetUserId, hidden)) throw new LeaderboardEntryNotFoundError({
93
+ message: "That entry does not exist.",
94
+ detail: `No entry for user ${targetUserId} on board "${b.key}" in window ${window}.`
95
+ });
96
+ return { hidden };
97
+ }
98
+ async function removeEntry(deps, boardKey, targetUserId, query) {
99
+ const b = board(deps, boardKey);
100
+ const window = readWindow(deps, b, query.window);
101
+ const removed = await entryStore(deps.db).remove(b.key, window, targetUserId);
102
+ if (!removed) throw new LeaderboardEntryNotFoundError({
103
+ message: "That entry does not exist.",
104
+ detail: `No entry for user ${targetUserId} on board "${b.key}" in window ${window}.`
105
+ });
106
+ return { removed };
107
+ }
108
+ /** The board set, as configured — what a client needs to render a board picker. */
109
+ function listBoards(deps) {
110
+ return { boards: deps.config.boards.map((b) => ({
111
+ key: b.key,
112
+ store: b.store,
113
+ direction: b.direction,
114
+ aggregation: b.aggregation,
115
+ window: b.window ?? null,
116
+ tiers: b.tiers?.map((t) => ({
117
+ key: t.key,
118
+ from: t.from
119
+ })) ?? []
120
+ })) };
121
+ }
122
+ //#endregion
123
+ export { hideEntry, listBoards, readAround, readOwnRank, readSegment, readTop, removeEntry, setOwnVisibility, submitScore };
@@ -0,0 +1,48 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
4
+ import type { Context, Hono } from "hono";
5
+ import type { LeaderboardConfig } from "../config/config";
6
+ import { type HandlerDeps } from "./handlers";
7
+ /**
8
+ * The leaderboard routes, each declaring both how a caller is verified and what it may send:
9
+ *
10
+ * GET /leaderboard → list boards (bearer | session) — takes nothing
11
+ * POST /leaderboard/:board → submit a score (bearer | session + submit scope)
12
+ * param BoardParam, json SubmitScoreBody
13
+ * GET /leaderboard/:board/top → top-N page (bearer | session)
14
+ * param BoardParam, query TopQuery
15
+ * POST /leaderboard/:board/segment → friends/cohort (bearer | session)
16
+ * param BoardParam, json SegmentBody
17
+ * GET /leaderboard/:board/me → my rank (bearer | session)
18
+ * param BoardParam, query WindowQuery
19
+ * GET /leaderboard/:board/around → around me (bearer | session)
20
+ * param BoardParam, query AroundQuery
21
+ * PUT /leaderboard/:board/me/visibility → my consent (bearer | session)
22
+ * param BoardParam, query WindowQuery, json VisibilityBody
23
+ * PUT /leaderboard/:board/entries/:userId/hidden → hide entry (bearer | session + admin scope)
24
+ * param EntryParam, query WindowQuery, json HideBody
25
+ * DELETE /leaderboard/:board/entries/:userId → remove entry (bearer | session + admin scope)
26
+ * param EntryParam, query WindowQuery
27
+ *
28
+ * Every route is gated by {@link requireAuth} — there is no public leaderboard surface, because an entry
29
+ * with no authenticated player has nothing to key on. Submit additionally requires the board's submit
30
+ * scope while `serverAuthoritative` is on (the default), and the two moderation routes require the admin
31
+ * scope. Turnstile, if the adopter runs it, stacks on top as middleware — it is a humanity check, not an
32
+ * identity, so it never replaces any of the above.
33
+ *
34
+ * Validators sit **after** the guards, never before: who you are is decided before what you sent, so an
35
+ * unauthorised request with a malformed body is still a 401. They sit **before** the handler, which is
36
+ * what moves a malformed request ahead of the board lookup — an unknown board sent a bad body now answers
37
+ * 400 rather than 404. The request was never well-formed enough to have a board.
38
+ */
39
+ export interface LeaderboardRoutesOptions {
40
+ config: LeaderboardConfig;
41
+ basePath?: string;
42
+ /** Test seam: resolve handler deps from the request context. Defaults to the env-based resolver. */
43
+ resolveDeps?: (c: Context<PithyHonoEnv>, write: boolean) => HandlerDeps & {
44
+ bookmark(): string | null;
45
+ };
46
+ }
47
+ export declare function registerLeaderboardRoutes(options: LeaderboardRoutesOptions): (app: Hono<PithyHonoEnv>) => void;
48
+ //# sourceMappingURL=routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/http/routes.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0CAA0C,CAAC;AAG7E,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC1C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAG1D,OAAO,EACL,KAAK,WAAW,EAUjB,MAAM,YAAY,CAAC;AAapB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,iBAAiB,CAAC;IAC1B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oGAAoG;IACpG,WAAW,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,EAAE,KAAK,EAAE,OAAO,KAAK,WAAW,GAAG;QAAE,QAAQ,IAAI,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;CACzG;AA2BD,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,wBAAwB,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,YAAY,CAAC,KAAK,IAAI,CAqG9G"}
@@ -0,0 +1,62 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { BOOKMARK_HEADER, leaderboardSession, readBookmark } from "../session/bookmark.js";
4
+ import { requireAdminScope, requireAuth, requireSubmitScope } from "./guard.js";
5
+ import { hideEntry, listBoards, readAround, readOwnRank, readSegment, readTop, removeEntry, setOwnVisibility, submitScore } from "./handlers.js";
6
+ import { AroundQuery, BoardParam, EntryParam, HideBody, SegmentBody, SubmitScoreBody, TopQuery, VisibilityBody, WindowQuery } from "./schemas.js";
7
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
8
+ import { zValidator } from "@hono/zod-validator";
9
+ import { validationHook } from "@pithy-sh/core/src/http/validation";
10
+ //#region src/http/routes.ts
11
+ function defaultResolveDeps(config) {
12
+ return (c, write) => {
13
+ const d1 = c.env.DB;
14
+ if (!d1) throw new InternalError({
15
+ message: "The leaderboard is not configured.",
16
+ action: "Bind a D1 database named DB in wrangler.jsonc.",
17
+ detail: "The leaderboard capability requires a `DB` D1 binding; none was present on env."
18
+ });
19
+ const session = leaderboardSession(d1, readBookmark(c.req.raw.headers), write ? "first-primary" : "first-unconstrained");
20
+ const auth = c.var.auth;
21
+ if (!auth) throw new InternalError({ detail: "requireAuth() must run before a leaderboard handler resolves deps." });
22
+ return {
23
+ config,
24
+ db: session.db,
25
+ userId: auth.userId,
26
+ now: () => /* @__PURE__ */ new Date(),
27
+ bookmark: session.bookmark
28
+ };
29
+ };
30
+ }
31
+ function registerLeaderboardRoutes(options) {
32
+ const base = options.basePath ?? "/leaderboard";
33
+ const resolve = options.resolveDeps ?? defaultResolveDeps(options.config);
34
+ const { serverAuthoritative, submitScope, adminScope } = options.config;
35
+ /** Run a handler and return its JSON, attaching the session's bookmark for the client to echo back. */
36
+ const respond = async (c, write, run, status = 200) => {
37
+ const deps = resolve(c, write);
38
+ const body = await run(deps);
39
+ const bookmark = deps.bookmark();
40
+ if (bookmark) c.header(BOOKMARK_HEADER, bookmark);
41
+ return c.json(body, status);
42
+ };
43
+ return (app) => {
44
+ app.get(base, requireAuth(), (c) => respond(c, false, (deps) => listBoards(deps)));
45
+ app.post(`${base}/:board/segment`, requireAuth(), zValidator("param", BoardParam, validationHook), zValidator("json", SegmentBody, validationHook), (c) => respond(c, false, (deps) => readSegment(deps, c.req.valid("param").board, c.req.valid("json"))));
46
+ app.get(`${base}/:board/top`, requireAuth(), zValidator("param", BoardParam, validationHook), zValidator("query", TopQuery, validationHook), (c) => respond(c, false, (deps) => readTop(deps, c.req.valid("param").board, c.req.valid("query"))));
47
+ app.get(`${base}/:board/me`, requireAuth(), zValidator("param", BoardParam, validationHook), zValidator("query", WindowQuery, validationHook), (c) => respond(c, false, (deps) => readOwnRank(deps, c.req.valid("param").board, c.req.valid("query"))));
48
+ app.get(`${base}/:board/around`, requireAuth(), zValidator("param", BoardParam, validationHook), zValidator("query", AroundQuery, validationHook), (c) => respond(c, false, (deps) => readAround(deps, c.req.valid("param").board, c.req.valid("query"))));
49
+ app.put(`${base}/:board/me/visibility`, requireAuth(), zValidator("param", BoardParam, validationHook), zValidator("query", WindowQuery, validationHook), zValidator("json", VisibilityBody, validationHook), (c) => respond(c, true, (deps) => setOwnVisibility(deps, c.req.valid("param").board, c.req.valid("query"), c.req.valid("json"))));
50
+ app.put(`${base}/:board/entries/:userId/hidden`, requireAuth(), requireAdminScope(adminScope), zValidator("param", EntryParam, validationHook), zValidator("query", WindowQuery, validationHook), zValidator("json", HideBody, validationHook), (c) => {
51
+ const { board, userId } = c.req.valid("param");
52
+ return respond(c, true, (deps) => hideEntry(deps, board, userId, c.req.valid("query"), c.req.valid("json")));
53
+ });
54
+ app.delete(`${base}/:board/entries/:userId`, requireAuth(), requireAdminScope(adminScope), zValidator("param", EntryParam, validationHook), zValidator("query", WindowQuery, validationHook), (c) => {
55
+ const { board, userId } = c.req.valid("param");
56
+ return respond(c, true, (deps) => removeEntry(deps, board, userId, c.req.valid("query")));
57
+ });
58
+ app.post(`${base}/:board`, requireAuth(), requireSubmitScope(serverAuthoritative, submitScope), zValidator("param", BoardParam, validationHook), zValidator("json", SubmitScoreBody, validationHook), (c) => respond(c, true, (deps) => submitScore(deps, c.req.valid("param").board, c.req.valid("json")), 201));
59
+ };
60
+ }
61
+ //#endregion
62
+ export { registerLeaderboardRoutes };
@@ -0,0 +1,47 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { z } from "zod";
4
+ export declare const BoardParam: z.ZodObject<{
5
+ board: z.ZodString;
6
+ }, z.core.$strip>;
7
+ export type BoardParam = z.infer<typeof BoardParam>;
8
+ export declare const EntryParam: z.ZodObject<{
9
+ board: z.ZodString;
10
+ userId: z.ZodString;
11
+ }, z.core.$strip>;
12
+ export type EntryParam = z.infer<typeof EntryParam>;
13
+ export declare const SubmitScoreBody: z.ZodObject<{
14
+ score: z.ZodNumber;
15
+ }, z.core.$strip>;
16
+ export type SubmitScoreBody = z.infer<typeof SubmitScoreBody>;
17
+ export declare const WindowQuery: z.ZodObject<{
18
+ window: z.ZodOptional<z.ZodString>;
19
+ }, z.core.$strip>;
20
+ export type WindowQuery = z.infer<typeof WindowQuery>;
21
+ export declare const TopQuery: z.ZodObject<{
22
+ window: z.ZodOptional<z.ZodString>;
23
+ limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
24
+ offset: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
25
+ }, z.core.$strip>;
26
+ export type TopQuery = z.infer<typeof TopQuery>;
27
+ export declare const AroundQuery: z.ZodObject<{
28
+ window: z.ZodOptional<z.ZodString>;
29
+ radius: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
30
+ }, z.core.$strip>;
31
+ export type AroundQuery = z.infer<typeof AroundQuery>;
32
+ export declare const SegmentBody: z.ZodObject<{
33
+ userIds: z.ZodArray<z.ZodString>;
34
+ limit: z.ZodDefault<z.ZodNumber>;
35
+ offset: z.ZodDefault<z.ZodNumber>;
36
+ window: z.ZodOptional<z.ZodString>;
37
+ }, z.core.$strip>;
38
+ export type SegmentBody = z.infer<typeof SegmentBody>;
39
+ export declare const VisibilityBody: z.ZodObject<{
40
+ visible: z.ZodBoolean;
41
+ }, z.core.$strip>;
42
+ export type VisibilityBody = z.infer<typeof VisibilityBody>;
43
+ export declare const HideBody: z.ZodObject<{
44
+ hidden: z.ZodBoolean;
45
+ }, z.core.$strip>;
46
+ export type HideBody = z.infer<typeof HideBody>;
47
+ //# sourceMappingURL=schemas.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../../src/http/schemas.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAmBxB,eAAO,MAAM,UAAU;;iBAW0C,CAAC;AAClE,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD,eAAO,MAAM,UAAU;;;iBAQ0C,CAAC;AAClE,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD,eAAO,MAAM,eAAe;;iBAOwE,CAAC;AACrG,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D,eAAO,MAAM,WAAW;;iBAS4B,CAAC;AACrD,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,eAAO,MAAM,QAAQ;;;;iBASwB,CAAC;AAC9C,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,QAAQ,CAAC,CAAC;AAEhD,eAAO,MAAM,WAAW;;;iBAQ2C,CAAC;AACpE,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,eAAO,MAAM,WAAW;;;;;iBAgB2B,CAAC;AACpD,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,eAAO,MAAM,cAAc;;iBAMqE,CAAC;AACjG,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D,eAAO,MAAM,QAAQ;;iBAI6E,CAAC;AACnG,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,QAAQ,CAAC,CAAC"}
@@ -0,0 +1,38 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { BOARD_KEY_PATTERN } from "../config/boardKey.js";
4
+ import "../rank/segment.js";
5
+ import { z } from "zod";
6
+ //#region src/http/schemas.ts
7
+ /**
8
+ * The HTTP boundary shapes. Everything a client can send is parsed through one of these before it
9
+ * reaches a handler — declared on the route line with `zValidator(target, Schema, validationHook)`, so
10
+ * reading a route tells you what it takes.
11
+ *
12
+ * Note what is absent from {@link SubmitScoreBody}: `userId`, `achievedAt`, and `rank`. The player comes
13
+ * from the AuthContext seam, the clock comes from the server, and the rank is derived — a client that
14
+ * could set any of them could score as someone else, backdate its way past the tiebreak, or simply
15
+ * declare itself first. Server-authoritative is not only about who may call submit; it is about which
16
+ * fields a caller may name at all.
17
+ */
18
+ /** A player id is opaque to us — it comes from the adopter's auth provider — so it is bounded, not shaped. */
19
+ const MAX_USER_ID_LENGTH = 256;
20
+ const BoardParam = z.object({ board: z.string().min(1).max(64).regex(BOARD_KEY_PATTERN, "A board key is lowercase, digits, and dashes — it is a URL path segment.").describe("Which board the route addresses. A shape check only: the same pattern config already enforces on every board key, so nothing that resolves today is rejected. Whether the key is *configured* stays the handler's 404.") }).describe("The board a `/leaderboard/:board` route addresses.");
21
+ const EntryParam = BoardParam.extend({ userId: z.string().min(1).max(MAX_USER_ID_LENGTH).describe("The player whose entry a moderation route targets. Bounded in length, not in charset — player ids are minted by your auth provider, so we cap what reaches the store rather than guess its format.") }).describe("The board and player a moderation route addresses.");
22
+ const SubmitScoreBody = z.object({ score: z.number().finite().describe("The score to submit. Folded into the player's entry by the board's aggregation.") }).describe("A score submission. The player, the window, and the timestamp are all server-derived.");
23
+ const WindowQuery = z.object({ window: z.string().optional().describe("Which window to read — the ISO key of a closed window, or omit for the one open now. Reading your own history is the point: these windows live in your D1 for as long as `retain` says, which no platform SDK offers.") }).describe("Selects the window a read applies to.");
24
+ const TopQuery = WindowQuery.extend({
25
+ limit: z.coerce.number().int().min(1).max(100).default(20).describe("How many entries to return, capped at 100 — a page, not the whole board."),
26
+ offset: z.coerce.number().int().min(0).default(0).describe("How many entries to skip. Ranks number from here.")
27
+ }).describe("A page of a board, best first.");
28
+ const AroundQuery = WindowQuery.extend({ radius: z.coerce.number().int().min(1).max(25).default(5).describe("How many entries to return either side of the player.") }).describe("The slice of a board centered on the calling player.");
29
+ const SegmentBody = z.object({
30
+ userIds: z.array(z.string().min(1)).min(1).max(80, `A segment is capped at 80 players: D1 allows 100 bound parameters per query and each member costs one.`).describe("The players to rank among — a friends list or any cohort. A collection dimension over the same store, not a second board."),
31
+ limit: z.number().int().min(1).max(100).default(20).describe("How many entries to return."),
32
+ offset: z.number().int().min(0).default(0).describe("How many entries to skip."),
33
+ window: z.string().optional().describe("Which window to read; omit for the one open now.")
34
+ }).describe("A friends or cohort view of a board.");
35
+ const VisibilityBody = z.object({ visible: z.boolean().describe("Whether this player consents to appear on the board. Gates every read, including segments.") }).describe("A player's own consent to be shown. Theirs to set — a submission never resets it.");
36
+ const HideBody = z.object({ hidden: z.boolean().describe("Whether to hide this entry from every read. The score is kept, not deleted.") }).describe("A moderator's hide toggle. Separate from player consent so a player cannot undo it.");
37
+ //#endregion
38
+ export { AroundQuery, BoardParam, EntryParam, HideBody, SegmentBody, SubmitScoreBody, TopQuery, VisibilityBody, WindowQuery };
@@ -0,0 +1,18 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * The package entrypoint — the surface `pithy add leaderboard` wires into `pithy.config.ts`. Deliberately
5
+ * narrow: the capability factory, its config and options types, and the read shapes an app renders. Every
6
+ * other module is imported by deep path (`@pithy-sh/leaderboard/src/...`); this is the documented
7
+ * contract, not a barrel over the package.
8
+ */
9
+ export { isLeaderboardCapability, LEADERBOARD_MIGRATION_ORDER, type LeaderboardCapability, type LeaderboardOptions, leaderboard, needsRankWorker, } from "./capability";
10
+ export { LeaderboardBoard, LeaderboardConfig, type LeaderboardConfigInput, LeaderboardRank, LeaderboardStore, LeaderboardTier, materializeSchedule, resolveBoard, ScoreAggregation, ScoreDirection, } from "./config/config";
11
+ export { LeaderboardEntry } from "./data/entry";
12
+ export type { OwnRank } from "./http/handlers";
13
+ export type { RankedEntry } from "./rank/query";
14
+ export { MAX_SEGMENT_SIZE } from "./rank/segment";
15
+ export { classifyTier } from "./rank/tiers";
16
+ export { BOOKMARK_HEADER } from "./session/bookmark";
17
+ export { ALL_TIME_WINDOW, previousWindowKeys, windowKeyAt } from "./window/schedule";
18
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA;;;;;GAKG;AAEH,OAAO,EACL,uBAAuB,EACvB,2BAA2B,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,kBAAkB,EACvB,WAAW,EACX,eAAe,GAChB,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,gBAAgB,EAChB,iBAAiB,EACjB,KAAK,sBAAsB,EAC3B,eAAe,EACf,gBAAgB,EAChB,eAAe,EACf,mBAAmB,EACnB,YAAY,EACZ,gBAAgB,EAChB,cAAc,GACf,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAChD,YAAY,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC/C,YAAY,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAChD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AACrD,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { ALL_TIME_WINDOW, previousWindowKeys, windowKeyAt } from "./window/schedule.js";
4
+ import { LeaderboardBoard, LeaderboardConfig, LeaderboardRank, LeaderboardStore, LeaderboardTier, ScoreAggregation, ScoreDirection, materializeSchedule, resolveBoard } from "./config/config.js";
5
+ import { LeaderboardEntry } from "./data/entry.js";
6
+ import { BOOKMARK_HEADER } from "./session/bookmark.js";
7
+ import { MAX_SEGMENT_SIZE } from "./rank/segment.js";
8
+ import { classifyTier } from "./rank/tiers.js";
9
+ import { LEADERBOARD_MIGRATION_ORDER, isLeaderboardCapability, leaderboard, needsRankWorker } from "./capability.js";
10
+ export { ALL_TIME_WINDOW, BOOKMARK_HEADER, LEADERBOARD_MIGRATION_ORDER, LeaderboardBoard, LeaderboardConfig, LeaderboardEntry, LeaderboardRank, LeaderboardStore, LeaderboardTier, MAX_SEGMENT_SIZE, ScoreAggregation, ScoreDirection, classifyTier, isLeaderboardCapability, leaderboard, materializeSchedule, needsRankWorker, previousWindowKeys, resolveBoard, windowKeyAt };
@@ -0,0 +1,10 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { Migration } from "kysely/migration";
4
+ /**
5
+ * The leaderboard tables: player entries, and the board drift guard.
6
+ *
7
+ * camelCase identifiers; `CamelCasePlugin` snake-cases them in the DDL. `down` is the tested inverse.
8
+ */
9
+ export declare const leaderboard_0001_entries: Migration;
10
+ //# sourceMappingURL=0001_entries.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"0001_entries.d.ts","sourceRoot":"","sources":["../../src/migrations/0001_entries.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAElD;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,SA0EtC,CAAC"}
@@ -0,0 +1,35 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/migrations/0001_entries.ts
4
+ /**
5
+ * The leaderboard tables: player entries, and the board drift guard.
6
+ *
7
+ * camelCase identifiers; `CamelCasePlugin` snake-cases them in the DDL. `down` is the tested inverse.
8
+ */
9
+ const leaderboard_0001_entries = {
10
+ up: async (db) => {
11
+ await db.schema.createTable("pithyLeaderboardEntries").addColumn("id", "integer", (c) => c.primaryKey()).addColumn("boardId", "text", (c) => c.notNull()).addColumn("windowKey", "text", (c) => c.notNull()).addColumn("userId", "text", (c) => c.notNull()).addColumn("score", "real", (c) => c.notNull()).addColumn("achievedAt", "integer", (c) => c.notNull()).addColumn("submittedAt", "integer", (c) => c.notNull()).addColumn("visible", "integer", (c) => c.notNull().defaultTo(1)).addColumn("hidden", "integer", (c) => c.notNull().defaultTo(0)).addColumn("rank", "integer").execute();
12
+ await db.schema.createIndex("pithyLeaderboardEntriesPlayerIdx").on("pithyLeaderboardEntries").columns([
13
+ "boardId",
14
+ "windowKey",
15
+ "userId"
16
+ ]).unique().execute();
17
+ await db.schema.createIndex("pithyLeaderboardEntriesRankIdx").on("pithyLeaderboardEntries").columns([
18
+ "boardId",
19
+ "windowKey",
20
+ "score",
21
+ "achievedAt"
22
+ ]).execute();
23
+ await db.schema.createTable("pithyLeaderboardBoards").addColumn("id", "integer", (c) => c.primaryKey()).addColumn("boardKey", "text", (c) => c.notNull().unique()).addColumn("store", "text", (c) => c.notNull().defaultTo("d1")).addColumn("direction", "text", (c) => c.notNull()).addColumn("aggregation", "text", (c) => c.notNull()).addColumn("window", "text").addColumn("createdAt", "integer", (c) => c.notNull()).execute();
24
+ await db.schema.createTable("pithyLeaderboardLocks").addColumn("name", "text", (c) => c.primaryKey()).addColumn("holder", "text", (c) => c.notNull()).addColumn("acquiredAt", "integer", (c) => c.notNull()).execute();
25
+ },
26
+ down: async (db) => {
27
+ await db.schema.dropTable("pithyLeaderboardLocks").execute();
28
+ await db.schema.dropTable("pithyLeaderboardBoards").execute();
29
+ await db.schema.dropIndex("pithyLeaderboardEntriesRankIdx").execute();
30
+ await db.schema.dropIndex("pithyLeaderboardEntriesPlayerIdx").execute();
31
+ await db.schema.dropTable("pithyLeaderboardEntries").execute();
32
+ }
33
+ };
34
+ //#endregion
35
+ export { leaderboard_0001_entries };
@@ -0,0 +1,39 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { type LeaderboardDatabase } from "../data/tables";
4
+ /**
5
+ * The rank-refresh advisory lock: at most one refresh runs at a time.
6
+ *
7
+ * Why it exists: the materialize refresh writes ranks in chunks, not atomically. If a cron fired again
8
+ * before a refresh finished, two passes would run concurrently and their chunked writes would interleave
9
+ * into an incoherent rank set — duplicate or gapped rank numbers — until the next fire corrected it. The
10
+ * lock serializes them: a second instance that cannot acquire it simply skips, and the pass it would have
11
+ * done is redundant anyway (the holder is already producing fresh ranks).
12
+ *
13
+ * The lock lives in D1, not in DO or KV, because the refresh already has the `DB` binding and D1's
14
+ * single-threaded execution makes the acquire genuinely atomic — the whole point.
15
+ */
16
+ /** The one lock name the refresh uses. A single-row lock; there is no per-board locking. */
17
+ export declare const REFRESH_LOCK = "rank-refresh";
18
+ /**
19
+ * How long a held lock stays valid before it is treated as abandoned by a crashed instance.
20
+ *
21
+ * A refresh that finishes releases the lock immediately, so this only matters when an instance dies
22
+ * mid-pass. It must be comfortably longer than any real refresh so a slow-but-alive instance is never
23
+ * stolen from; one hour is far past the ~5-minute worst case at the ~1M-player shard boundary. Override
24
+ * with `LEADERBOARD_LOCK_STALE_MS` if a deployment refreshes boards larger than that.
25
+ */
26
+ export declare const DEFAULT_LOCK_STALE_MS: number;
27
+ /**
28
+ * Try to take the refresh lock for `holder`, returning whether it was acquired.
29
+ *
30
+ * Atomic on D1's single thread: the upsert either inserts the row (no holder yet) or, on conflict, takes
31
+ * it over only if the current holder's lock is older than `staleMs` — a fresh lock is left untouched.
32
+ * A concurrent second caller therefore either finds no row and loses the insert race, or finds a fresh
33
+ * lock and is refused by the `WHERE`. Either way it reads back a `holder` that is not its own and knows
34
+ * to stand down.
35
+ */
36
+ export declare function acquireRefreshLock(db: LeaderboardDatabase, holder: string, now: Date, staleMs?: number): Promise<boolean>;
37
+ /** Release the lock if `holder` still holds it. A no-op if it was already reclaimed or released. */
38
+ export declare function releaseRefreshLock(db: LeaderboardDatabase, holder: string): Promise<void>;
39
+ //# sourceMappingURL=lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lock.d.ts","sourceRoot":"","sources":["../../src/rank/lock.ts"],"names":[],"mappings":"AAIA,OAAO,EAA2B,KAAK,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAEnF;;;;;;;;;;;GAWG;AAEH,4FAA4F;AAC5F,eAAO,MAAM,YAAY,iBAAiB,CAAC;AAE3C;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,QAAiB,CAAC;AAEpD;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,mBAAmB,EACvB,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,IAAI,EACT,OAAO,GAAE,MAA8B,GACtC,OAAO,CAAC,OAAO,CAAC,CAuBlB;AAED,oGAAoG;AACpG,wBAAsB,kBAAkB,CAAC,EAAE,EAAE,mBAAmB,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE/F"}
@@ -0,0 +1,56 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { LeaderboardLock } from "../data/lock.js";
4
+ import { LEADERBOARD_LOCKS_TABLE } from "../data/tables.js";
5
+ //#region src/rank/lock.ts
6
+ /**
7
+ * The rank-refresh advisory lock: at most one refresh runs at a time.
8
+ *
9
+ * Why it exists: the materialize refresh writes ranks in chunks, not atomically. If a cron fired again
10
+ * before a refresh finished, two passes would run concurrently and their chunked writes would interleave
11
+ * into an incoherent rank set — duplicate or gapped rank numbers — until the next fire corrected it. The
12
+ * lock serializes them: a second instance that cannot acquire it simply skips, and the pass it would have
13
+ * done is redundant anyway (the holder is already producing fresh ranks).
14
+ *
15
+ * The lock lives in D1, not in DO or KV, because the refresh already has the `DB` binding and D1's
16
+ * single-threaded execution makes the acquire genuinely atomic — the whole point.
17
+ */
18
+ /** The one lock name the refresh uses. A single-row lock; there is no per-board locking. */
19
+ const REFRESH_LOCK = "rank-refresh";
20
+ /**
21
+ * How long a held lock stays valid before it is treated as abandoned by a crashed instance.
22
+ *
23
+ * A refresh that finishes releases the lock immediately, so this only matters when an instance dies
24
+ * mid-pass. It must be comfortably longer than any real refresh so a slow-but-alive instance is never
25
+ * stolen from; one hour is far past the ~5-minute worst case at the ~1M-player shard boundary. Override
26
+ * with `LEADERBOARD_LOCK_STALE_MS` if a deployment refreshes boards larger than that.
27
+ */
28
+ const DEFAULT_LOCK_STALE_MS = 36e5;
29
+ /**
30
+ * Try to take the refresh lock for `holder`, returning whether it was acquired.
31
+ *
32
+ * Atomic on D1's single thread: the upsert either inserts the row (no holder yet) or, on conflict, takes
33
+ * it over only if the current holder's lock is older than `staleMs` — a fresh lock is left untouched.
34
+ * A concurrent second caller therefore either finds no row and loses the insert race, or finds a fresh
35
+ * lock and is refused by the `WHERE`. Either way it reads back a `holder` that is not its own and knows
36
+ * to stand down.
37
+ */
38
+ async function acquireRefreshLock(db, holder, now, staleMs = DEFAULT_LOCK_STALE_MS) {
39
+ const staleBefore = LeaderboardLock.shape.acquiredAt.encode(new Date(now.getTime() - staleMs));
40
+ const row = LeaderboardLock.encode({
41
+ name: REFRESH_LOCK,
42
+ holder,
43
+ acquiredAt: now
44
+ });
45
+ await db.insertInto(LEADERBOARD_LOCKS_TABLE).values(row).onConflict((oc) => oc.column("name").doUpdateSet({
46
+ holder,
47
+ acquiredAt: LeaderboardLock.shape.acquiredAt.encode(now)
48
+ }).where("pithyLeaderboardLocks.acquiredAt", "<", staleBefore)).execute();
49
+ return (await db.selectFrom(LEADERBOARD_LOCKS_TABLE).select("holder").where("name", "=", REFRESH_LOCK).executeTakeFirst())?.holder === holder;
50
+ }
51
+ /** Release the lock if `holder` still holds it. A no-op if it was already reclaimed or released. */
52
+ async function releaseRefreshLock(db, holder) {
53
+ await db.deleteFrom(LEADERBOARD_LOCKS_TABLE).where("name", "=", REFRESH_LOCK).where("holder", "=", holder).execute();
54
+ }
55
+ //#endregion
56
+ export { DEFAULT_LOCK_STALE_MS, REFRESH_LOCK, acquireRefreshLock, releaseRefreshLock };
@@ -0,0 +1,97 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { LeaderboardBoard } from "../config/config";
4
+ import { type LeaderboardDatabase } from "../data/tables";
5
+ /**
6
+ * The rank refresh pass — what `rank: { materialize }` buys and what it costs.
7
+ *
8
+ * A full-table rank rewrite is the obvious implementation and the wrong one. D1 executes one query at a
9
+ * time per database and caps a query at 30 seconds; a single `UPDATE` over a large board would hold the
10
+ * only thread for its whole duration and risk the documented `overloaded` error for every live
11
+ * submission behind it. So the pass is chunked: many small, bounded statements the runtime can
12
+ * interleave with real traffic.
13
+ *
14
+ * A chunk is a **pacing** unit: how much of the board one keyset step walks, kept well inside D1's
15
+ * 30-second per-query limit. It is no longer also the width of a statement — the bulk update sizes
16
+ * itself against D1's bound-parameter cap through core's arithmetic, so a chunk of any size is written
17
+ * in as many statements as it takes. That separation is the fix for #250: `chunkSize` was unvalidated,
18
+ * and `chunkSize: 40` bound 120 and broke the pass with no warning that a limit was even involved.
19
+ *
20
+ * Chunks walk the board by keyset, not by `OFFSET`: an offset page makes SQLite count past every row it
21
+ * skips, so an offset walk is quadratic in billed rows — the very cost `materialize` exists to avoid.
22
+ *
23
+ * Cloudflare documents no rank-materialization pattern. All of this is adopter-built, which is exactly
24
+ * why it lives in the package instead of in every adopter's repo.
25
+ */
26
+ /**
27
+ * What one ranked row costs the bulk update: `WHEN id`, `THEN rank`, and the id again in the `IN` list.
28
+ *
29
+ * The cap itself is not restated here. It was, and a second copy of a platform limit is how a limit goes
30
+ * stale in one place and not the other — `MAX_BOUND_PARAMETERS` lives in `@pithy-sh/core`, once.
31
+ */
32
+ export declare const RANK_PARAMETERS_PER_ROW = 3;
33
+ /**
34
+ * Rows per chunk by default: as many as one update statement can carry, from core's budget.
35
+ *
36
+ * Derived rather than written out, so it moves if the platform does. Any other size works — the update
37
+ * chunks itself — and this is simply the size at which a chunk is exactly one statement.
38
+ */
39
+ export declare const RANK_CHUNK_SIZE: number;
40
+ /**
41
+ * Chunks a refresh ranks before it checkpoints its cursor. `2000 * 33` is ~66k entries per Workflow step.
42
+ *
43
+ * The batch cap is stated here rather than in `worker.entry.ts`, which is the module that passes it as
44
+ * `maxChunks`. That module imports `cloudflare:workers`, so anything it exports is unreachable from a
45
+ * plain Node process, and a constant that reads as ordinary is exactly how #172 and #180 happened twice:
46
+ * a Node-side caller imports the number, gets workerd behind it, and the failure surfaces as
47
+ * `Could not load pithy.config.ts` — naming the config rather than the import. A pure value belongs in a
48
+ * pure module. `configEntrypoints.test.ts` is what holds that.
49
+ */
50
+ export declare const REFRESH_BATCH_CHUNKS = 2000;
51
+ /** The last entry a chunk ranked — where the next chunk (or the next batch's step) resumes. */
52
+ export interface Keyset {
53
+ score: number;
54
+ achievedAt: number;
55
+ userId: string;
56
+ }
57
+ export interface RefreshResult {
58
+ /** The cumulative number of entries ranked, including any `startRank` carried in from a prior batch. */
59
+ ranked: number;
60
+ /** How many chunks ran in this call. */
61
+ chunks: number;
62
+ /** True when the board is fully ranked; false when `maxChunks` stopped this batch mid-board. */
63
+ complete: boolean;
64
+ /**
65
+ * Where the next batch resumes, or null when the board is complete. Passing this back as `resumeAfter`
66
+ * (with `startRank` set to `ranked`) continues the pass exactly where it left off — the seam that makes
67
+ * a board rankable across many bounded steps of a Workflow, rather than one unbounded invocation.
68
+ */
69
+ cursor: Keyset | null;
70
+ }
71
+ export interface RefreshOptions {
72
+ /**
73
+ * Rows per keyset step. Defaults to {@link RANK_CHUNK_SIZE}; lower it to be gentler on a hot database.
74
+ *
75
+ * It carries no bound-parameter ceiling — the update chunks itself — so the only thing refused is a
76
+ * value that is not a count. A `chunkSize` of 0 used to report a board complete having ranked nobody.
77
+ */
78
+ chunkSize?: number;
79
+ /** Stop cleanly once this many chunks have run, whether or not the board is finished. Default: no cap. */
80
+ maxChunks?: number;
81
+ /** Resume the keyset walk after this entry — the `cursor` a prior batch returned. */
82
+ resumeAfter?: Keyset;
83
+ /** The rank already assigned before this batch, so numbering continues rather than restarting at 1. */
84
+ startRank?: number;
85
+ }
86
+ /**
87
+ * Recompute the stored rank for one board and window, best first.
88
+ *
89
+ * Ranks are positions in the total ordering, so a chunk needs no counting — the first chunk's first row
90
+ * is rank 1 and every chunk continues the count. The pass is **resumable**: with no `maxChunks` it ranks
91
+ * the whole board; with a `maxChunks` batch cap it ranks that many chunks, returns a `cursor`, and the
92
+ * caller feeds the cursor (and `startRank: ranked`) back to continue. That is what lets a board of any
93
+ * size be ranked across a series of bounded, individually-durable Workflow steps — there is no longer a
94
+ * per-invocation ceiling on how many entries a board can have.
95
+ */
96
+ export declare function refreshWindowRanks(db: LeaderboardDatabase, board: LeaderboardBoard, windowKey: string, options?: RefreshOptions): Promise<RefreshResult>;
97
+ //# sourceMappingURL=materialize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"materialize.d.ts","sourceRoot":"","sources":["../../src/rank/materialize.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACzD,OAAO,EAA6B,KAAK,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAErF;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,IAAI,CAAC;AAEzC;;;;;GAKG;AACH,eAAO,MAAM,eAAe,QAAgE,CAAC;AAE7F;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,OAAO,CAAC;AAEzC,+FAA+F;AAC/F,MAAM,WAAW,MAAM;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;CAChB;AAaD,MAAM,WAAW,aAAa;IAC5B,wGAAwG;IACxG,MAAM,EAAE,MAAM,CAAC;IACf,wCAAwC;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,gGAAgG;IAChG,QAAQ,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,MAAM,WAAW,cAAc;IAC7B;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0GAA0G;IAC1G,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qFAAqF;IACrF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uGAAuG;IACvG,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,mBAAmB,EACvB,KAAK,EAAE,gBAAgB,EACvB,SAAS,EAAE,MAAM,EACjB,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,aAAa,CAAC,CAkFxB"}