@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.
- package/dist/board/registry.d.ts +22 -0
- package/dist/board/registry.d.ts.map +1 -0
- package/dist/board/registry.js +52 -0
- package/dist/capability.d.ts +43 -0
- package/dist/capability.d.ts.map +1 -0
- package/dist/capability.js +73 -0
- package/dist/config/boardKey.d.ts +14 -0
- package/dist/config/boardKey.d.ts.map +1 -0
- package/dist/config/boardKey.js +16 -0
- package/dist/config/config.d.ts +96 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +121 -0
- package/dist/data/boardRecord.d.ts +36 -0
- package/dist/data/boardRecord.d.ts.map +1 -0
- package/dist/data/boardRecord.js +31 -0
- package/dist/data/entry.d.ts +29 -0
- package/dist/data/entry.d.ts.map +1 -0
- package/dist/data/entry.js +30 -0
- package/dist/data/lock.d.ts +18 -0
- package/dist/data/lock.d.ts.map +1 -0
- package/dist/data/lock.js +19 -0
- package/dist/data/tables.d.ts +25 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/data/tables.js +29 -0
- package/dist/entry/store.d.ts +40 -0
- package/dist/entry/store.d.ts.map +1 -0
- package/dist/entry/store.js +84 -0
- package/dist/error/errors.d.ts +54 -0
- package/dist/error/errors.d.ts.map +1 -0
- package/dist/error/errors.js +76 -0
- package/dist/http/guard.d.ts +24 -0
- package/dist/http/guard.d.ts.map +1 -0
- package/dist/http/guard.js +48 -0
- package/dist/http/handlers.d.ts +79 -0
- package/dist/http/handlers.d.ts.map +1 -0
- package/dist/http/handlers.js +121 -0
- package/dist/http/routes.d.ts +46 -0
- package/dist/http/routes.d.ts.map +1 -0
- package/dist/http/routes.js +60 -0
- package/dist/http/schemas.d.ts +45 -0
- package/dist/http/schemas.d.ts.map +1 -0
- package/dist/http/schemas.js +36 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/migrations/0001_entries.d.ts +8 -0
- package/dist/migrations/0001_entries.d.ts.map +1 -0
- package/dist/migrations/0001_entries.js +33 -0
- package/dist/rank/lock.d.ts +37 -0
- package/dist/rank/lock.d.ts.map +1 -0
- package/dist/rank/lock.js +54 -0
- package/dist/rank/materialize.d.ts +95 -0
- package/dist/rank/materialize.d.ts.map +1 -0
- package/dist/rank/materialize.js +140 -0
- package/dist/rank/query.d.ts +50 -0
- package/dist/rank/query.d.ts.map +1 -0
- package/dist/rank/query.js +140 -0
- package/dist/rank/retryPolicy.d.ts +33 -0
- package/dist/rank/retryPolicy.d.ts.map +1 -0
- package/dist/rank/retryPolicy.js +37 -0
- package/dist/rank/segment.d.ts +33 -0
- package/dist/rank/segment.d.ts.map +1 -0
- package/dist/rank/segment.js +35 -0
- package/dist/rank/tiers.d.ts +13 -0
- package/dist/rank/tiers.d.ts.map +1 -0
- package/dist/rank/tiers.js +22 -0
- package/dist/rank/worker.d.ts +59 -0
- package/dist/rank/worker.d.ts.map +1 -0
- package/dist/rank/worker.entry.d.ts +57 -0
- package/dist/rank/worker.entry.d.ts.map +1 -0
- package/dist/rank/worker.entry.js +63 -0
- package/dist/rank/worker.js +52 -0
- package/dist/retention/prune.d.ts +39 -0
- package/dist/retention/prune.d.ts.map +1 -0
- package/dist/retention/prune.js +65 -0
- package/dist/seeds/example.d.ts +11 -0
- package/dist/seeds/example.d.ts.map +1 -0
- package/dist/seeds/example.js +67 -0
- package/dist/session/bookmark.d.ts +47 -0
- package/dist/session/bookmark.d.ts.map +1 -0
- package/dist/session/bookmark.js +41 -0
- package/dist/version.generated.d.ts +5 -0
- package/dist/version.generated.d.ts.map +1 -0
- package/dist/version.generated.js +7 -0
- package/dist/window/schedule.d.ts +29 -0
- package/dist/window/schedule.d.ts.map +1 -0
- package/dist/window/schedule.js +106 -0
- package/package.json +21 -10
- 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 };
|
package/dist/index.d.ts
ADDED
|
@@ -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 };
|