@pithy-sh/leaderboard 0.1.2 → 0.1.3

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 +22 -0
  2. package/dist/board/registry.d.ts.map +1 -0
  3. package/dist/board/registry.js +52 -0
  4. package/dist/capability.d.ts +43 -0
  5. package/dist/capability.d.ts.map +1 -0
  6. package/dist/capability.js +73 -0
  7. package/dist/config/boardKey.d.ts +14 -0
  8. package/dist/config/boardKey.d.ts.map +1 -0
  9. package/dist/config/boardKey.js +16 -0
  10. package/dist/config/config.d.ts +96 -0
  11. package/dist/config/config.d.ts.map +1 -0
  12. package/dist/config/config.js +121 -0
  13. package/dist/data/boardRecord.d.ts +36 -0
  14. package/dist/data/boardRecord.d.ts.map +1 -0
  15. package/dist/data/boardRecord.js +31 -0
  16. package/dist/data/entry.d.ts +29 -0
  17. package/dist/data/entry.d.ts.map +1 -0
  18. package/dist/data/entry.js +30 -0
  19. package/dist/data/lock.d.ts +18 -0
  20. package/dist/data/lock.d.ts.map +1 -0
  21. package/dist/data/lock.js +19 -0
  22. package/dist/data/tables.d.ts +25 -0
  23. package/dist/data/tables.d.ts.map +1 -0
  24. package/dist/data/tables.js +29 -0
  25. package/dist/entry/store.d.ts +40 -0
  26. package/dist/entry/store.d.ts.map +1 -0
  27. package/dist/entry/store.js +84 -0
  28. package/dist/error/errors.d.ts +54 -0
  29. package/dist/error/errors.d.ts.map +1 -0
  30. package/dist/error/errors.js +76 -0
  31. package/dist/http/guard.d.ts +24 -0
  32. package/dist/http/guard.d.ts.map +1 -0
  33. package/dist/http/guard.js +48 -0
  34. package/dist/http/handlers.d.ts +79 -0
  35. package/dist/http/handlers.d.ts.map +1 -0
  36. package/dist/http/handlers.js +121 -0
  37. package/dist/http/routes.d.ts +46 -0
  38. package/dist/http/routes.d.ts.map +1 -0
  39. package/dist/http/routes.js +60 -0
  40. package/dist/http/schemas.d.ts +45 -0
  41. package/dist/http/schemas.d.ts.map +1 -0
  42. package/dist/http/schemas.js +36 -0
  43. package/dist/index.d.ts +16 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +8 -0
  46. package/dist/migrations/0001_entries.d.ts +8 -0
  47. package/dist/migrations/0001_entries.d.ts.map +1 -0
  48. package/dist/migrations/0001_entries.js +33 -0
  49. package/dist/rank/lock.d.ts +37 -0
  50. package/dist/rank/lock.d.ts.map +1 -0
  51. package/dist/rank/lock.js +54 -0
  52. package/dist/rank/materialize.d.ts +95 -0
  53. package/dist/rank/materialize.d.ts.map +1 -0
  54. package/dist/rank/materialize.js +140 -0
  55. package/dist/rank/query.d.ts +50 -0
  56. package/dist/rank/query.d.ts.map +1 -0
  57. package/dist/rank/query.js +140 -0
  58. package/dist/rank/retryPolicy.d.ts +33 -0
  59. package/dist/rank/retryPolicy.d.ts.map +1 -0
  60. package/dist/rank/retryPolicy.js +37 -0
  61. package/dist/rank/segment.d.ts +33 -0
  62. package/dist/rank/segment.d.ts.map +1 -0
  63. package/dist/rank/segment.js +35 -0
  64. package/dist/rank/tiers.d.ts +13 -0
  65. package/dist/rank/tiers.d.ts.map +1 -0
  66. package/dist/rank/tiers.js +22 -0
  67. package/dist/rank/worker.d.ts +59 -0
  68. package/dist/rank/worker.d.ts.map +1 -0
  69. package/dist/rank/worker.entry.d.ts +57 -0
  70. package/dist/rank/worker.entry.d.ts.map +1 -0
  71. package/dist/rank/worker.entry.js +63 -0
  72. package/dist/rank/worker.js +52 -0
  73. package/dist/retention/prune.d.ts +39 -0
  74. package/dist/retention/prune.d.ts.map +1 -0
  75. package/dist/retention/prune.js +65 -0
  76. package/dist/seeds/example.d.ts +11 -0
  77. package/dist/seeds/example.d.ts.map +1 -0
  78. package/dist/seeds/example.js +67 -0
  79. package/dist/session/bookmark.d.ts +47 -0
  80. package/dist/session/bookmark.d.ts.map +1 -0
  81. package/dist/session/bookmark.js +41 -0
  82. package/dist/version.generated.d.ts +5 -0
  83. package/dist/version.generated.d.ts.map +1 -0
  84. package/dist/version.generated.js +7 -0
  85. package/dist/window/schedule.d.ts +29 -0
  86. package/dist/window/schedule.d.ts.map +1 -0
  87. package/dist/window/schedule.js +106 -0
  88. package/package.json +21 -10
  89. package/src/version.generated.ts +1 -1
@@ -0,0 +1,46 @@
1
+ import type { PithyHonoEnv } from "@pithy-sh/core/src/capability/capability";
2
+ import type { Context, Hono } from "hono";
3
+ import type { LeaderboardConfig } from "../config/config";
4
+ import { type HandlerDeps } from "./handlers";
5
+ /**
6
+ * The leaderboard routes, each declaring both how a caller is verified and what it may send:
7
+ *
8
+ * GET /leaderboard → list boards (bearer | session) — takes nothing
9
+ * POST /leaderboard/:board → submit a score (bearer | session + submit scope)
10
+ * param BoardParam, json SubmitScoreBody
11
+ * GET /leaderboard/:board/top → top-N page (bearer | session)
12
+ * param BoardParam, query TopQuery
13
+ * POST /leaderboard/:board/segment → friends/cohort (bearer | session)
14
+ * param BoardParam, json SegmentBody
15
+ * GET /leaderboard/:board/me → my rank (bearer | session)
16
+ * param BoardParam, query WindowQuery
17
+ * GET /leaderboard/:board/around → around me (bearer | session)
18
+ * param BoardParam, query AroundQuery
19
+ * PUT /leaderboard/:board/me/visibility → my consent (bearer | session)
20
+ * param BoardParam, query WindowQuery, json VisibilityBody
21
+ * PUT /leaderboard/:board/entries/:userId/hidden → hide entry (bearer | session + admin scope)
22
+ * param EntryParam, query WindowQuery, json HideBody
23
+ * DELETE /leaderboard/:board/entries/:userId → remove entry (bearer | session + admin scope)
24
+ * param EntryParam, query WindowQuery
25
+ *
26
+ * Every route is gated by {@link requireAuth} — there is no public leaderboard surface, because an entry
27
+ * with no authenticated player has nothing to key on. Submit additionally requires the board's submit
28
+ * scope while `serverAuthoritative` is on (the default), and the two moderation routes require the admin
29
+ * scope. Turnstile, if the adopter runs it, stacks on top as middleware — it is a humanity check, not an
30
+ * identity, so it never replaces any of the above.
31
+ *
32
+ * Validators sit **after** the guards, never before: who you are is decided before what you sent, so an
33
+ * unauthorised request with a malformed body is still a 401. They sit **before** the handler, which is
34
+ * what moves a malformed request ahead of the board lookup — an unknown board sent a bad body now answers
35
+ * 400 rather than 404. The request was never well-formed enough to have a board.
36
+ */
37
+ export interface LeaderboardRoutesOptions {
38
+ config: LeaderboardConfig;
39
+ basePath?: string;
40
+ /** Test seam: resolve handler deps from the request context. Defaults to the env-based resolver. */
41
+ resolveDeps?: (c: Context<PithyHonoEnv>, write: boolean) => HandlerDeps & {
42
+ bookmark(): string | null;
43
+ };
44
+ }
45
+ export declare function registerLeaderboardRoutes(options: LeaderboardRoutesOptions): (app: Hono<PithyHonoEnv>) => void;
46
+ //# 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,60 @@
1
+ import { BOOKMARK_HEADER, leaderboardSession, readBookmark } from "../session/bookmark.js";
2
+ import { requireAdminScope, requireAuth, requireSubmitScope } from "./guard.js";
3
+ import { hideEntry, listBoards, readAround, readOwnRank, readSegment, readTop, removeEntry, setOwnVisibility, submitScore } from "./handlers.js";
4
+ import { AroundQuery, BoardParam, EntryParam, HideBody, SegmentBody, SubmitScoreBody, TopQuery, VisibilityBody, WindowQuery } from "./schemas.js";
5
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
6
+ import { zValidator } from "@hono/zod-validator";
7
+ import { validationHook } from "@pithy-sh/core/src/http/validation";
8
+ //#region src/http/routes.ts
9
+ function defaultResolveDeps(config) {
10
+ return (c, write) => {
11
+ const d1 = c.env.DB;
12
+ if (!d1) throw new InternalError({
13
+ message: "The leaderboard is not configured.",
14
+ action: "Bind a D1 database named DB in wrangler.jsonc.",
15
+ detail: "The leaderboard capability requires a `DB` D1 binding; none was present on env."
16
+ });
17
+ const session = leaderboardSession(d1, readBookmark(c.req.raw.headers), write ? "first-primary" : "first-unconstrained");
18
+ const auth = c.var.auth;
19
+ if (!auth) throw new InternalError({ detail: "requireAuth() must run before a leaderboard handler resolves deps." });
20
+ return {
21
+ config,
22
+ db: session.db,
23
+ userId: auth.userId,
24
+ now: () => /* @__PURE__ */ new Date(),
25
+ bookmark: session.bookmark
26
+ };
27
+ };
28
+ }
29
+ function registerLeaderboardRoutes(options) {
30
+ const base = options.basePath ?? "/leaderboard";
31
+ const resolve = options.resolveDeps ?? defaultResolveDeps(options.config);
32
+ const { serverAuthoritative, submitScope, adminScope } = options.config;
33
+ /** Run a handler and return its JSON, attaching the session's bookmark for the client to echo back. */
34
+ const respond = async (c, write, run, status = 200) => {
35
+ const deps = resolve(c, write);
36
+ const body = await run(deps);
37
+ const bookmark = deps.bookmark();
38
+ if (bookmark) c.header(BOOKMARK_HEADER, bookmark);
39
+ return c.json(body, status);
40
+ };
41
+ return (app) => {
42
+ app.get(base, requireAuth(), (c) => respond(c, false, (deps) => listBoards(deps)));
43
+ 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"))));
44
+ 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"))));
45
+ 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"))));
46
+ 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"))));
47
+ 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"))));
48
+ app.put(`${base}/:board/entries/:userId/hidden`, requireAuth(), requireAdminScope(adminScope), zValidator("param", EntryParam, validationHook), zValidator("query", WindowQuery, validationHook), zValidator("json", HideBody, validationHook), (c) => {
49
+ const { board, userId } = c.req.valid("param");
50
+ return respond(c, true, (deps) => hideEntry(deps, board, userId, c.req.valid("query"), c.req.valid("json")));
51
+ });
52
+ app.delete(`${base}/:board/entries/:userId`, requireAuth(), requireAdminScope(adminScope), zValidator("param", EntryParam, validationHook), zValidator("query", WindowQuery, validationHook), (c) => {
53
+ const { board, userId } = c.req.valid("param");
54
+ return respond(c, true, (deps) => removeEntry(deps, board, userId, c.req.valid("query")));
55
+ });
56
+ 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));
57
+ };
58
+ }
59
+ //#endregion
60
+ export { registerLeaderboardRoutes };
@@ -0,0 +1,45 @@
1
+ import { z } from "zod";
2
+ export declare const BoardParam: z.ZodObject<{
3
+ board: z.ZodString;
4
+ }, z.core.$strip>;
5
+ export type BoardParam = z.infer<typeof BoardParam>;
6
+ export declare const EntryParam: z.ZodObject<{
7
+ board: z.ZodString;
8
+ userId: z.ZodString;
9
+ }, z.core.$strip>;
10
+ export type EntryParam = z.infer<typeof EntryParam>;
11
+ export declare const SubmitScoreBody: z.ZodObject<{
12
+ score: z.ZodNumber;
13
+ }, z.core.$strip>;
14
+ export type SubmitScoreBody = z.infer<typeof SubmitScoreBody>;
15
+ export declare const WindowQuery: z.ZodObject<{
16
+ window: z.ZodOptional<z.ZodString>;
17
+ }, z.core.$strip>;
18
+ export type WindowQuery = z.infer<typeof WindowQuery>;
19
+ export declare const TopQuery: z.ZodObject<{
20
+ window: z.ZodOptional<z.ZodString>;
21
+ limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
22
+ offset: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
23
+ }, z.core.$strip>;
24
+ export type TopQuery = z.infer<typeof TopQuery>;
25
+ export declare const AroundQuery: z.ZodObject<{
26
+ window: z.ZodOptional<z.ZodString>;
27
+ radius: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
28
+ }, z.core.$strip>;
29
+ export type AroundQuery = z.infer<typeof AroundQuery>;
30
+ export declare const SegmentBody: z.ZodObject<{
31
+ userIds: z.ZodArray<z.ZodString>;
32
+ limit: z.ZodDefault<z.ZodNumber>;
33
+ offset: z.ZodDefault<z.ZodNumber>;
34
+ window: z.ZodOptional<z.ZodString>;
35
+ }, z.core.$strip>;
36
+ export type SegmentBody = z.infer<typeof SegmentBody>;
37
+ export declare const VisibilityBody: z.ZodObject<{
38
+ visible: z.ZodBoolean;
39
+ }, z.core.$strip>;
40
+ export type VisibilityBody = z.infer<typeof VisibilityBody>;
41
+ export declare const HideBody: z.ZodObject<{
42
+ hidden: z.ZodBoolean;
43
+ }, z.core.$strip>;
44
+ export type HideBody = z.infer<typeof HideBody>;
45
+ //# 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,36 @@
1
+ import { BOARD_KEY_PATTERN } from "../config/boardKey.js";
2
+ import "../rank/segment.js";
3
+ import { z } from "zod";
4
+ //#region src/http/schemas.ts
5
+ /**
6
+ * The HTTP boundary shapes. Everything a client can send is parsed through one of these before it
7
+ * reaches a handler — declared on the route line with `zValidator(target, Schema, validationHook)`, so
8
+ * reading a route tells you what it takes.
9
+ *
10
+ * Note what is absent from {@link SubmitScoreBody}: `userId`, `achievedAt`, and `rank`. The player comes
11
+ * from the AuthContext seam, the clock comes from the server, and the rank is derived — a client that
12
+ * could set any of them could score as someone else, backdate its way past the tiebreak, or simply
13
+ * declare itself first. Server-authoritative is not only about who may call submit; it is about which
14
+ * fields a caller may name at all.
15
+ */
16
+ /** A player id is opaque to us — it comes from the adopter's auth provider — so it is bounded, not shaped. */
17
+ const MAX_USER_ID_LENGTH = 256;
18
+ 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.");
19
+ 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.");
20
+ 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.");
21
+ 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.");
22
+ const TopQuery = WindowQuery.extend({
23
+ 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."),
24
+ offset: z.coerce.number().int().min(0).default(0).describe("How many entries to skip. Ranks number from here.")
25
+ }).describe("A page of a board, best first.");
26
+ 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.");
27
+ const SegmentBody = z.object({
28
+ 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."),
29
+ limit: z.number().int().min(1).max(100).default(20).describe("How many entries to return."),
30
+ offset: z.number().int().min(0).default(0).describe("How many entries to skip."),
31
+ window: z.string().optional().describe("Which window to read; omit for the one open now.")
32
+ }).describe("A friends or cohort view of a board.");
33
+ 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.");
34
+ 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.");
35
+ //#endregion
36
+ export { AroundQuery, BoardParam, EntryParam, HideBody, SegmentBody, SubmitScoreBody, TopQuery, VisibilityBody, WindowQuery };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The package entrypoint — the surface `pithy add leaderboard` wires into `pithy.config.ts`. Deliberately
3
+ * narrow: the capability factory, its config and options types, and the read shapes an app renders. Every
4
+ * other module is imported by deep path (`@pithy-sh/leaderboard/src/...`); this is the documented
5
+ * contract, not a barrel over the package.
6
+ */
7
+ export { isLeaderboardCapability, LEADERBOARD_MIGRATION_ORDER, type LeaderboardCapability, type LeaderboardOptions, leaderboard, needsRankWorker, } from "./capability";
8
+ export { LeaderboardBoard, LeaderboardConfig, type LeaderboardConfigInput, LeaderboardRank, LeaderboardStore, LeaderboardTier, materializeSchedule, resolveBoard, ScoreAggregation, ScoreDirection, } from "./config/config";
9
+ export { LeaderboardEntry } from "./data/entry";
10
+ export type { OwnRank } from "./http/handlers";
11
+ export type { RankedEntry } from "./rank/query";
12
+ export { MAX_SEGMENT_SIZE } from "./rank/segment";
13
+ export { classifyTier } from "./rank/tiers";
14
+ export { BOOKMARK_HEADER } from "./session/bookmark";
15
+ export { ALL_TIME_WINDOW, previousWindowKeys, windowKeyAt } from "./window/schedule";
16
+ //# 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,8 @@
1
+ import { ALL_TIME_WINDOW, previousWindowKeys, windowKeyAt } from "./window/schedule.js";
2
+ import { LeaderboardBoard, LeaderboardConfig, LeaderboardRank, LeaderboardStore, LeaderboardTier, ScoreAggregation, ScoreDirection, materializeSchedule, resolveBoard } from "./config/config.js";
3
+ import { LeaderboardEntry } from "./data/entry.js";
4
+ import { BOOKMARK_HEADER } from "./session/bookmark.js";
5
+ import { MAX_SEGMENT_SIZE } from "./rank/segment.js";
6
+ import { classifyTier } from "./rank/tiers.js";
7
+ import { LEADERBOARD_MIGRATION_ORDER, isLeaderboardCapability, leaderboard, needsRankWorker } from "./capability.js";
8
+ 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,8 @@
1
+ import type { Migration } from "kysely/migration";
2
+ /**
3
+ * The leaderboard tables: player entries, and the board drift guard.
4
+ *
5
+ * camelCase identifiers; `CamelCasePlugin` snake-cases them in the DDL. `down` is the tested inverse.
6
+ */
7
+ export declare const leaderboard_0001_entries: Migration;
8
+ //# 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,33 @@
1
+ //#region src/migrations/0001_entries.ts
2
+ /**
3
+ * The leaderboard tables: player entries, and the board drift guard.
4
+ *
5
+ * camelCase identifiers; `CamelCasePlugin` snake-cases them in the DDL. `down` is the tested inverse.
6
+ */
7
+ const leaderboard_0001_entries = {
8
+ up: async (db) => {
9
+ 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();
10
+ await db.schema.createIndex("pithyLeaderboardEntriesPlayerIdx").on("pithyLeaderboardEntries").columns([
11
+ "boardId",
12
+ "windowKey",
13
+ "userId"
14
+ ]).unique().execute();
15
+ await db.schema.createIndex("pithyLeaderboardEntriesRankIdx").on("pithyLeaderboardEntries").columns([
16
+ "boardId",
17
+ "windowKey",
18
+ "score",
19
+ "achievedAt"
20
+ ]).execute();
21
+ 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();
22
+ await db.schema.createTable("pithyLeaderboardLocks").addColumn("name", "text", (c) => c.primaryKey()).addColumn("holder", "text", (c) => c.notNull()).addColumn("acquiredAt", "integer", (c) => c.notNull()).execute();
23
+ },
24
+ down: async (db) => {
25
+ await db.schema.dropTable("pithyLeaderboardLocks").execute();
26
+ await db.schema.dropTable("pithyLeaderboardBoards").execute();
27
+ await db.schema.dropIndex("pithyLeaderboardEntriesRankIdx").execute();
28
+ await db.schema.dropIndex("pithyLeaderboardEntriesPlayerIdx").execute();
29
+ await db.schema.dropTable("pithyLeaderboardEntries").execute();
30
+ }
31
+ };
32
+ //#endregion
33
+ export { leaderboard_0001_entries };
@@ -0,0 +1,37 @@
1
+ import { type LeaderboardDatabase } from "../data/tables";
2
+ /**
3
+ * The rank-refresh advisory lock: at most one refresh runs at a time.
4
+ *
5
+ * Why it exists: the materialize refresh writes ranks in chunks, not atomically. If a cron fired again
6
+ * before a refresh finished, two passes would run concurrently and their chunked writes would interleave
7
+ * into an incoherent rank set — duplicate or gapped rank numbers — until the next fire corrected it. The
8
+ * lock serializes them: a second instance that cannot acquire it simply skips, and the pass it would have
9
+ * done is redundant anyway (the holder is already producing fresh ranks).
10
+ *
11
+ * The lock lives in D1, not in DO or KV, because the refresh already has the `DB` binding and D1's
12
+ * single-threaded execution makes the acquire genuinely atomic — the whole point.
13
+ */
14
+ /** The one lock name the refresh uses. A single-row lock; there is no per-board locking. */
15
+ export declare const REFRESH_LOCK = "rank-refresh";
16
+ /**
17
+ * How long a held lock stays valid before it is treated as abandoned by a crashed instance.
18
+ *
19
+ * A refresh that finishes releases the lock immediately, so this only matters when an instance dies
20
+ * mid-pass. It must be comfortably longer than any real refresh so a slow-but-alive instance is never
21
+ * stolen from; one hour is far past the ~5-minute worst case at the ~1M-player shard boundary. Override
22
+ * with `LEADERBOARD_LOCK_STALE_MS` if a deployment refreshes boards larger than that.
23
+ */
24
+ export declare const DEFAULT_LOCK_STALE_MS: number;
25
+ /**
26
+ * Try to take the refresh lock for `holder`, returning whether it was acquired.
27
+ *
28
+ * Atomic on D1's single thread: the upsert either inserts the row (no holder yet) or, on conflict, takes
29
+ * it over only if the current holder's lock is older than `staleMs` — a fresh lock is left untouched.
30
+ * A concurrent second caller therefore either finds no row and loses the insert race, or finds a fresh
31
+ * lock and is refused by the `WHERE`. Either way it reads back a `holder` that is not its own and knows
32
+ * to stand down.
33
+ */
34
+ export declare function acquireRefreshLock(db: LeaderboardDatabase, holder: string, now: Date, staleMs?: number): Promise<boolean>;
35
+ /** Release the lock if `holder` still holds it. A no-op if it was already reclaimed or released. */
36
+ export declare function releaseRefreshLock(db: LeaderboardDatabase, holder: string): Promise<void>;
37
+ //# 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,54 @@
1
+ import { LeaderboardLock } from "../data/lock.js";
2
+ import { LEADERBOARD_LOCKS_TABLE } from "../data/tables.js";
3
+ //#region src/rank/lock.ts
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
+ 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
+ const DEFAULT_LOCK_STALE_MS = 36e5;
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
+ async function acquireRefreshLock(db, holder, now, staleMs = DEFAULT_LOCK_STALE_MS) {
37
+ const staleBefore = LeaderboardLock.shape.acquiredAt.encode(new Date(now.getTime() - staleMs));
38
+ const row = LeaderboardLock.encode({
39
+ name: REFRESH_LOCK,
40
+ holder,
41
+ acquiredAt: now
42
+ });
43
+ await db.insertInto(LEADERBOARD_LOCKS_TABLE).values(row).onConflict((oc) => oc.column("name").doUpdateSet({
44
+ holder,
45
+ acquiredAt: LeaderboardLock.shape.acquiredAt.encode(now)
46
+ }).where("pithyLeaderboardLocks.acquiredAt", "<", staleBefore)).execute();
47
+ return (await db.selectFrom(LEADERBOARD_LOCKS_TABLE).select("holder").where("name", "=", REFRESH_LOCK).executeTakeFirst())?.holder === holder;
48
+ }
49
+ /** Release the lock if `holder` still holds it. A no-op if it was already reclaimed or released. */
50
+ async function releaseRefreshLock(db, holder) {
51
+ await db.deleteFrom(LEADERBOARD_LOCKS_TABLE).where("name", "=", REFRESH_LOCK).where("holder", "=", holder).execute();
52
+ }
53
+ //#endregion
54
+ export { DEFAULT_LOCK_STALE_MS, REFRESH_LOCK, acquireRefreshLock, releaseRefreshLock };
@@ -0,0 +1,95 @@
1
+ import type { LeaderboardBoard } from "../config/config";
2
+ import { type LeaderboardDatabase } from "../data/tables";
3
+ /**
4
+ * The rank refresh pass — what `rank: { materialize }` buys and what it costs.
5
+ *
6
+ * A full-table rank rewrite is the obvious implementation and the wrong one. D1 executes one query at a
7
+ * time per database and caps a query at 30 seconds; a single `UPDATE` over a large board would hold the
8
+ * only thread for its whole duration and risk the documented `overloaded` error for every live
9
+ * submission behind it. So the pass is chunked: many small, bounded statements the runtime can
10
+ * interleave with real traffic.
11
+ *
12
+ * A chunk is a **pacing** unit: how much of the board one keyset step walks, kept well inside D1's
13
+ * 30-second per-query limit. It is no longer also the width of a statement — the bulk update sizes
14
+ * itself against D1's bound-parameter cap through core's arithmetic, so a chunk of any size is written
15
+ * in as many statements as it takes. That separation is the fix for #250: `chunkSize` was unvalidated,
16
+ * and `chunkSize: 40` bound 120 and broke the pass with no warning that a limit was even involved.
17
+ *
18
+ * Chunks walk the board by keyset, not by `OFFSET`: an offset page makes SQLite count past every row it
19
+ * skips, so an offset walk is quadratic in billed rows — the very cost `materialize` exists to avoid.
20
+ *
21
+ * Cloudflare documents no rank-materialization pattern. All of this is adopter-built, which is exactly
22
+ * why it lives in the package instead of in every adopter's repo.
23
+ */
24
+ /**
25
+ * What one ranked row costs the bulk update: `WHEN id`, `THEN rank`, and the id again in the `IN` list.
26
+ *
27
+ * The cap itself is not restated here. It was, and a second copy of a platform limit is how a limit goes
28
+ * stale in one place and not the other — `MAX_BOUND_PARAMETERS` lives in `@pithy-sh/core`, once.
29
+ */
30
+ export declare const RANK_PARAMETERS_PER_ROW = 3;
31
+ /**
32
+ * Rows per chunk by default: as many as one update statement can carry, from core's budget.
33
+ *
34
+ * Derived rather than written out, so it moves if the platform does. Any other size works — the update
35
+ * chunks itself — and this is simply the size at which a chunk is exactly one statement.
36
+ */
37
+ export declare const RANK_CHUNK_SIZE: number;
38
+ /**
39
+ * Chunks a refresh ranks before it checkpoints its cursor. `2000 * 33` is ~66k entries per Workflow step.
40
+ *
41
+ * The batch cap is stated here rather than in `worker.entry.ts`, which is the module that passes it as
42
+ * `maxChunks`. That module imports `cloudflare:workers`, so anything it exports is unreachable from a
43
+ * plain Node process, and a constant that reads as ordinary is exactly how #172 and #180 happened twice:
44
+ * a Node-side caller imports the number, gets workerd behind it, and the failure surfaces as
45
+ * `Could not load pithy.config.ts` — naming the config rather than the import. A pure value belongs in a
46
+ * pure module. `configEntrypoints.test.ts` is what holds that.
47
+ */
48
+ export declare const REFRESH_BATCH_CHUNKS = 2000;
49
+ /** The last entry a chunk ranked — where the next chunk (or the next batch's step) resumes. */
50
+ export interface Keyset {
51
+ score: number;
52
+ achievedAt: number;
53
+ userId: string;
54
+ }
55
+ export interface RefreshResult {
56
+ /** The cumulative number of entries ranked, including any `startRank` carried in from a prior batch. */
57
+ ranked: number;
58
+ /** How many chunks ran in this call. */
59
+ chunks: number;
60
+ /** True when the board is fully ranked; false when `maxChunks` stopped this batch mid-board. */
61
+ complete: boolean;
62
+ /**
63
+ * Where the next batch resumes, or null when the board is complete. Passing this back as `resumeAfter`
64
+ * (with `startRank` set to `ranked`) continues the pass exactly where it left off — the seam that makes
65
+ * a board rankable across many bounded steps of a Workflow, rather than one unbounded invocation.
66
+ */
67
+ cursor: Keyset | null;
68
+ }
69
+ export interface RefreshOptions {
70
+ /**
71
+ * Rows per keyset step. Defaults to {@link RANK_CHUNK_SIZE}; lower it to be gentler on a hot database.
72
+ *
73
+ * It carries no bound-parameter ceiling — the update chunks itself — so the only thing refused is a
74
+ * value that is not a count. A `chunkSize` of 0 used to report a board complete having ranked nobody.
75
+ */
76
+ chunkSize?: number;
77
+ /** Stop cleanly once this many chunks have run, whether or not the board is finished. Default: no cap. */
78
+ maxChunks?: number;
79
+ /** Resume the keyset walk after this entry — the `cursor` a prior batch returned. */
80
+ resumeAfter?: Keyset;
81
+ /** The rank already assigned before this batch, so numbering continues rather than restarting at 1. */
82
+ startRank?: number;
83
+ }
84
+ /**
85
+ * Recompute the stored rank for one board and window, best first.
86
+ *
87
+ * Ranks are positions in the total ordering, so a chunk needs no counting — the first chunk's first row
88
+ * is rank 1 and every chunk continues the count. The pass is **resumable**: with no `maxChunks` it ranks
89
+ * the whole board; with a `maxChunks` batch cap it ranks that many chunks, returns a `cursor`, and the
90
+ * caller feeds the cursor (and `startRank: ranked`) back to continue. That is what lets a board of any
91
+ * size be ranked across a series of bounded, individually-durable Workflow steps — there is no longer a
92
+ * per-invocation ceiling on how many entries a board can have.
93
+ */
94
+ export declare function refreshWindowRanks(db: LeaderboardDatabase, board: LeaderboardBoard, windowKey: string, options?: RefreshOptions): Promise<RefreshResult>;
95
+ //# 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"}
@@ -0,0 +1,140 @@
1
+ import { LEADERBOARD_ENTRIES_TABLE } from "../data/tables.js";
2
+ import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
3
+ import { sql } from "kysely";
4
+ import { boundParameterBudget, chunkRowsByBoundParameters } from "@pithy-sh/core/src/data/boundParameters";
5
+ //#region src/rank/materialize.ts
6
+ /**
7
+ * The rank refresh pass — what `rank: { materialize }` buys and what it costs.
8
+ *
9
+ * A full-table rank rewrite is the obvious implementation and the wrong one. D1 executes one query at a
10
+ * time per database and caps a query at 30 seconds; a single `UPDATE` over a large board would hold the
11
+ * only thread for its whole duration and risk the documented `overloaded` error for every live
12
+ * submission behind it. So the pass is chunked: many small, bounded statements the runtime can
13
+ * interleave with real traffic.
14
+ *
15
+ * A chunk is a **pacing** unit: how much of the board one keyset step walks, kept well inside D1's
16
+ * 30-second per-query limit. It is no longer also the width of a statement — the bulk update sizes
17
+ * itself against D1's bound-parameter cap through core's arithmetic, so a chunk of any size is written
18
+ * in as many statements as it takes. That separation is the fix for #250: `chunkSize` was unvalidated,
19
+ * and `chunkSize: 40` bound 120 and broke the pass with no warning that a limit was even involved.
20
+ *
21
+ * Chunks walk the board by keyset, not by `OFFSET`: an offset page makes SQLite count past every row it
22
+ * skips, so an offset walk is quadratic in billed rows — the very cost `materialize` exists to avoid.
23
+ *
24
+ * Cloudflare documents no rank-materialization pattern. All of this is adopter-built, which is exactly
25
+ * why it lives in the package instead of in every adopter's repo.
26
+ */
27
+ /**
28
+ * What one ranked row costs the bulk update: `WHEN id`, `THEN rank`, and the id again in the `IN` list.
29
+ *
30
+ * The cap itself is not restated here. It was, and a second copy of a platform limit is how a limit goes
31
+ * stale in one place and not the other — `MAX_BOUND_PARAMETERS` lives in `@pithy-sh/core`, once.
32
+ */
33
+ const RANK_PARAMETERS_PER_ROW = 3;
34
+ /**
35
+ * Rows per chunk by default: as many as one update statement can carry, from core's budget.
36
+ *
37
+ * Derived rather than written out, so it moves if the platform does. Any other size works — the update
38
+ * chunks itself — and this is simply the size at which a chunk is exactly one statement.
39
+ */
40
+ const RANK_CHUNK_SIZE = Math.floor(boundParameterBudget(0) / 3);
41
+ /**
42
+ * Chunks a refresh ranks before it checkpoints its cursor. `2000 * 33` is ~66k entries per Workflow step.
43
+ *
44
+ * The batch cap is stated here rather than in `worker.entry.ts`, which is the module that passes it as
45
+ * `maxChunks`. That module imports `cloudflare:workers`, so anything it exports is unreachable from a
46
+ * plain Node process, and a constant that reads as ordinary is exactly how #172 and #180 happened twice:
47
+ * a Node-side caller imports the number, gets workerd behind it, and the failure surfaces as
48
+ * `Could not load pithy.config.ts` — naming the config rather than the import. A pure value belongs in a
49
+ * pure module. `configEntrypoints.test.ts` is what holds that.
50
+ */
51
+ const REFRESH_BATCH_CHUNKS = 2e3;
52
+ /**
53
+ * Narrow a selected `achievedAt` to its stored epoch.
54
+ *
55
+ * The column's decode-side input is a union (`number | string | Date`) so the codec stays
56
+ * encode-compatible, but a chunk selects raw columns rather than parsing whole rows — D1 always hands
57
+ * back the stored integer. This keeps the keyset arithmetic honest without paying to parse every row.
58
+ */
59
+ function toEpoch(value) {
60
+ return value instanceof Date ? value.getTime() : Number(value);
61
+ }
62
+ /**
63
+ * Recompute the stored rank for one board and window, best first.
64
+ *
65
+ * Ranks are positions in the total ordering, so a chunk needs no counting — the first chunk's first row
66
+ * is rank 1 and every chunk continues the count. The pass is **resumable**: with no `maxChunks` it ranks
67
+ * the whole board; with a `maxChunks` batch cap it ranks that many chunks, returns a `cursor`, and the
68
+ * caller feeds the cursor (and `startRank: ranked`) back to continue. That is what lets a board of any
69
+ * size be ranked across a series of bounded, individually-durable Workflow steps — there is no longer a
70
+ * per-invocation ceiling on how many entries a board can have.
71
+ */
72
+ async function refreshWindowRanks(db, board, windowKey, options = {}) {
73
+ const chunkSize = options.chunkSize ?? RANK_CHUNK_SIZE;
74
+ if (!Number.isInteger(chunkSize) || chunkSize < 1) throw new ValidationError({
75
+ message: "The rank refresh was asked for an impossible chunk size.",
76
+ action: "Pass a whole number of one or more, or omit chunkSize for the default.",
77
+ detail: `refreshWindowRanks received chunkSize=${chunkSize}; it must be a positive integer.`
78
+ });
79
+ const maxChunks = options.maxChunks ?? Number.POSITIVE_INFINITY;
80
+ const descending = board.direction === "desc";
81
+ let after = options.resumeAfter;
82
+ let ranked = options.startRank ?? 0;
83
+ let chunks = 0;
84
+ while (chunks < maxChunks) {
85
+ let query = db.selectFrom(LEADERBOARD_ENTRIES_TABLE).select([
86
+ "id",
87
+ "score",
88
+ "achievedAt",
89
+ "userId"
90
+ ]).where("boardId", "=", board.key).where("windowKey", "=", windowKey).where("visible", "=", 1).where("hidden", "=", 0);
91
+ if (after) {
92
+ const scoreWorse = descending ? sql`score < ${after.score}` : sql`score > ${after.score}`;
93
+ query = query.where(sql`(
94
+ ${scoreWorse}
95
+ OR (score = ${after.score} AND achieved_at > ${after.achievedAt})
96
+ OR (score = ${after.score} AND achieved_at = ${after.achievedAt} AND user_id > ${after.userId})
97
+ )`);
98
+ }
99
+ const rows = await query.orderBy("score", descending ? "desc" : "asc").orderBy("achievedAt", "asc").orderBy("userId", "asc").limit(chunkSize).execute();
100
+ if (rows.length === 0) return {
101
+ ranked,
102
+ chunks,
103
+ complete: true,
104
+ cursor: null
105
+ };
106
+ let written = 0;
107
+ for (const group of chunkRowsByBoundParameters(rows, 3)) {
108
+ const base = ranked + written;
109
+ let cases = sql``;
110
+ group.forEach((row, index) => {
111
+ cases = sql`${cases} WHEN ${row.id} THEN ${base + index + 1}`;
112
+ });
113
+ await db.updateTable(LEADERBOARD_ENTRIES_TABLE).set({ rank: sql`CASE id ${cases} END` }).where("id", "in", group.map((row) => row.id)).execute();
114
+ written += group.length;
115
+ }
116
+ ranked += rows.length;
117
+ chunks += 1;
118
+ const last = rows[rows.length - 1];
119
+ if (!last) break;
120
+ after = {
121
+ score: last.score,
122
+ achievedAt: toEpoch(last.achievedAt),
123
+ userId: last.userId
124
+ };
125
+ if (rows.length < chunkSize) return {
126
+ ranked,
127
+ chunks,
128
+ complete: true,
129
+ cursor: null
130
+ };
131
+ }
132
+ return {
133
+ ranked,
134
+ chunks,
135
+ complete: false,
136
+ cursor: after ?? null
137
+ };
138
+ }
139
+ //#endregion
140
+ export { RANK_CHUNK_SIZE, RANK_PARAMETERS_PER_ROW, REFRESH_BATCH_CHUNKS, refreshWindowRanks };