@pithy-sh/leaderboard 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/dist/board/registry.d.ts +24 -0
  2. package/dist/board/registry.d.ts.map +1 -0
  3. package/dist/board/registry.js +54 -0
  4. package/dist/capability.d.ts +45 -0
  5. package/dist/capability.d.ts.map +1 -0
  6. package/dist/capability.js +75 -0
  7. package/dist/config/boardKey.d.ts +16 -0
  8. package/dist/config/boardKey.d.ts.map +1 -0
  9. package/dist/config/boardKey.js +18 -0
  10. package/dist/config/config.d.ts +98 -0
  11. package/dist/config/config.d.ts.map +1 -0
  12. package/dist/config/config.js +123 -0
  13. package/dist/data/boardRecord.d.ts +38 -0
  14. package/dist/data/boardRecord.d.ts.map +1 -0
  15. package/dist/data/boardRecord.js +33 -0
  16. package/dist/data/entry.d.ts +31 -0
  17. package/dist/data/entry.d.ts.map +1 -0
  18. package/dist/data/entry.js +32 -0
  19. package/dist/data/lock.d.ts +20 -0
  20. package/dist/data/lock.d.ts.map +1 -0
  21. package/dist/data/lock.js +21 -0
  22. package/dist/data/tables.d.ts +27 -0
  23. package/dist/data/tables.d.ts.map +1 -0
  24. package/dist/data/tables.js +31 -0
  25. package/dist/entry/store.d.ts +42 -0
  26. package/dist/entry/store.d.ts.map +1 -0
  27. package/dist/entry/store.js +86 -0
  28. package/dist/error/errors.d.ts +56 -0
  29. package/dist/error/errors.d.ts.map +1 -0
  30. package/dist/error/errors.js +78 -0
  31. package/dist/http/guard.d.ts +26 -0
  32. package/dist/http/guard.d.ts.map +1 -0
  33. package/dist/http/guard.js +50 -0
  34. package/dist/http/handlers.d.ts +81 -0
  35. package/dist/http/handlers.d.ts.map +1 -0
  36. package/dist/http/handlers.js +123 -0
  37. package/dist/http/routes.d.ts +48 -0
  38. package/dist/http/routes.d.ts.map +1 -0
  39. package/dist/http/routes.js +62 -0
  40. package/dist/http/schemas.d.ts +47 -0
  41. package/dist/http/schemas.d.ts.map +1 -0
  42. package/dist/http/schemas.js +38 -0
  43. package/dist/index.d.ts +18 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +10 -0
  46. package/dist/migrations/0001_entries.d.ts +10 -0
  47. package/dist/migrations/0001_entries.d.ts.map +1 -0
  48. package/dist/migrations/0001_entries.js +35 -0
  49. package/dist/rank/lock.d.ts +39 -0
  50. package/dist/rank/lock.d.ts.map +1 -0
  51. package/dist/rank/lock.js +56 -0
  52. package/dist/rank/materialize.d.ts +97 -0
  53. package/dist/rank/materialize.d.ts.map +1 -0
  54. package/dist/rank/materialize.js +142 -0
  55. package/dist/rank/query.d.ts +52 -0
  56. package/dist/rank/query.d.ts.map +1 -0
  57. package/dist/rank/query.js +142 -0
  58. package/dist/rank/retryPolicy.d.ts +35 -0
  59. package/dist/rank/retryPolicy.d.ts.map +1 -0
  60. package/dist/rank/retryPolicy.js +39 -0
  61. package/dist/rank/segment.d.ts +35 -0
  62. package/dist/rank/segment.d.ts.map +1 -0
  63. package/dist/rank/segment.js +37 -0
  64. package/dist/rank/tiers.d.ts +15 -0
  65. package/dist/rank/tiers.d.ts.map +1 -0
  66. package/dist/rank/tiers.js +24 -0
  67. package/dist/rank/worker.d.ts +61 -0
  68. package/dist/rank/worker.d.ts.map +1 -0
  69. package/dist/rank/worker.entry.d.ts +59 -0
  70. package/dist/rank/worker.entry.d.ts.map +1 -0
  71. package/dist/rank/worker.entry.js +65 -0
  72. package/dist/rank/worker.js +54 -0
  73. package/dist/retention/prune.d.ts +41 -0
  74. package/dist/retention/prune.d.ts.map +1 -0
  75. package/dist/retention/prune.js +67 -0
  76. package/dist/seeds/example.d.ts +13 -0
  77. package/dist/seeds/example.d.ts.map +1 -0
  78. package/dist/seeds/example.js +69 -0
  79. package/dist/session/bookmark.d.ts +49 -0
  80. package/dist/session/bookmark.d.ts.map +1 -0
  81. package/dist/session/bookmark.js +43 -0
  82. package/dist/version.generated.d.ts +7 -0
  83. package/dist/version.generated.d.ts.map +1 -0
  84. package/dist/version.generated.js +9 -0
  85. package/dist/window/schedule.d.ts +31 -0
  86. package/dist/window/schedule.d.ts.map +1 -0
  87. package/dist/window/schedule.js +108 -0
  88. package/package.json +21 -10
  89. package/src/version.generated.ts +1 -1
@@ -0,0 +1,59 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
4
+ import type { D1Database } from "@cloudflare/workers-types";
5
+ /**
6
+ * The leaderboard rank refresh, as a cron-triggered Cloudflare Workflow.
7
+ *
8
+ * Why a Workflow rather than a plain `scheduled()` pass: a Worker invocation is wall-clock bounded, so
9
+ * an earlier design capped each run at ~64k entries and re-ranked from the top every tick — a board
10
+ * bigger than that never fully ranked. A Workflow step has unlimited wall-clock and does not burn CPU
11
+ * while awaiting D1 (all our work is D1 I/O), so a board of any size ranks across a series of bounded,
12
+ * individually-durable steps. Each step checkpoints the keyset cursor; a crash resumes from the last
13
+ * completed step instead of restarting. The 64k ceiling is gone.
14
+ *
15
+ * At-most-one at a time: a refresh takes a D1 advisory lock before it starts and releases it when done.
16
+ * If a cron fires again while a refresh is still running (a cron faster than the refresh takes), the
17
+ * second instance cannot take the lock and skips — so two passes never interleave their chunked writes
18
+ * into an incoherent rank set. A crashed instance's lock ages out (see `lock.ts`) so the next fire
19
+ * reclaims it.
20
+ *
21
+ * Why it does NOT require a dedicated worker: this is a Workflow class plus a `scheduled()` handler plus
22
+ * one cron trigger. `pithy add leaderboard` deploys it as its own small worker by default (the template
23
+ * in `wrangler.jsonc`), but the same three pieces can be merged into the adopter's app worker — the
24
+ * capability contributes the Workflow through its manifest. A cron trigger is the one hard requirement;
25
+ * a separate worker is not.
26
+ *
27
+ * Cost: on Workers Paid this stays inside the free allowances at any realistic cadence — a run is a
28
+ * handful of steps (one prune + a few per board), storage is zero (all state is D1), and idle/awaiting
29
+ * steps incur no CPU. Even firing every minute is well under the 500k-steps/month and 10M-requests/month
30
+ * included tiers.
31
+ */
32
+ interface RankWorkerEnv {
33
+ DB: D1Database;
34
+ /** The resolved leaderboard config, as JSON. The board set is config; this worker needs the same one. */
35
+ LEADERBOARD_CONFIG: string;
36
+ /** Optional override for how long a held refresh lock stays valid before it is reclaimed (see lock.ts). */
37
+ LEADERBOARD_LOCK_STALE_MS?: string;
38
+ /** The Workflow binding — this worker's own class, used by `scheduled()` to start an instance. */
39
+ RANK_REFRESH: {
40
+ create(options?: {
41
+ id?: string;
42
+ }): Promise<unknown>;
43
+ };
44
+ }
45
+ export declare class RankRefreshWorkflow extends WorkflowEntrypoint<RankWorkerEnv, unknown> {
46
+ run(_event: WorkflowEvent<unknown>, step: WorkflowStep): Promise<void>;
47
+ }
48
+ declare const _default: {
49
+ /**
50
+ * Cron entry: start one rank-refresh Workflow instance per fire. Each instance re-ranks from the top;
51
+ * the checkpointing is within an instance (resume on crash), not across fires (a fire is a fresh full
52
+ * refresh). If a fire lands while a previous refresh is still running, the new instance takes no lock
53
+ * and exits immediately, so overlapping instances never write concurrently — see the lock acquisition
54
+ * in `run()` above.
55
+ */
56
+ scheduled(_controller: unknown, env: RankWorkerEnv): Promise<void>;
57
+ };
58
+ export default _default;
59
+ //# sourceMappingURL=worker.entry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker.entry.d.ts","sourceRoot":"","sources":["../../src/rank/worker.entry.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,kBAAkB,EAAE,KAAK,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAE/F,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAW5D;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,UAAU,aAAa;IACrB,EAAE,EAAE,UAAU,CAAC;IACf,yGAAyG;IACzG,kBAAkB,EAAE,MAAM,CAAC;IAC3B,2GAA2G;IAC3G,yBAAyB,CAAC,EAAE,MAAM,CAAC;IACnC,kGAAkG;IAClG,YAAY,EAAE;QAAE,MAAM,CAAC,OAAO,CAAC,EAAE;YAAE,EAAE,CAAC,EAAE,MAAM,CAAA;SAAE,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;KAAE,CAAC;CACvE;AAED,qBAAa,mBAAoB,SAAQ,kBAAkB,CAAC,aAAa,EAAE,OAAO,CAAC;IAClE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA+DpF;CACF;;IAGC;;;;;;OAMG;IACG,SAAS,cAAc,OAAO,OAAO,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC"}
@@ -0,0 +1,65 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { windowKeyAt } from "../window/schedule.js";
4
+ import { LeaderboardConfig } from "../config/config.js";
5
+ import { leaderboardDatabase } from "../data/tables.js";
6
+ import { acquireRefreshLock, releaseRefreshLock } from "./lock.js";
7
+ import { REFRESH_BATCH_CHUNKS, refreshWindowRanks } from "./materialize.js";
8
+ import { leaderboardWorkflowRetry } from "./retryPolicy.js";
9
+ import { pruneBoards } from "../retention/prune.js";
10
+ import { materializedBoards } from "./worker.js";
11
+ import { WorkflowEntrypoint } from "cloudflare:workers";
12
+ import { NonRetryableError } from "cloudflare:workflows";
13
+ import { classifiedSteps } from "@pithy-sh/core/src/workflow/faults";
14
+ //#region src/rank/worker.entry.ts
15
+ var RankRefreshWorkflow = class extends WorkflowEntrypoint {
16
+ async run(_event, step) {
17
+ const steps = classifiedSteps(step, leaderboardWorkflowRetry, NonRetryableError);
18
+ const config = LeaderboardConfig.parse(JSON.parse(this.env.LEADERBOARD_CONFIG));
19
+ const db = leaderboardDatabase(this.env.DB);
20
+ const staleMs = this.env.LEADERBOARD_LOCK_STALE_MS ? Number(this.env.LEADERBOARD_LOCK_STALE_MS) : void 0;
21
+ const ctx = await steps.do("refresh-context", async () => ({
22
+ holder: crypto.randomUUID(),
23
+ nowMs: Date.now()
24
+ }));
25
+ const now = new Date(ctx.nowMs);
26
+ if (!await acquireRefreshLock(db, ctx.holder, now, staleMs)) return;
27
+ try {
28
+ await steps.do("prune", () => pruneBoards(db, config.boards, now));
29
+ for (const board of materializedBoards(config)) {
30
+ const window = windowKeyAt(board.window, now);
31
+ let cursor = null;
32
+ let startRank = 0;
33
+ let batch = 0;
34
+ for (;;) {
35
+ const from = cursor ?? void 0;
36
+ const result = await steps.do(`refresh:${board.key}:${batch}`, () => refreshWindowRanks(db, board, window, {
37
+ maxChunks: REFRESH_BATCH_CHUNKS,
38
+ resumeAfter: from,
39
+ startRank
40
+ }));
41
+ if (result.complete) break;
42
+ cursor = result.cursor;
43
+ startRank = result.ranked;
44
+ batch += 1;
45
+ if (!cursor) break;
46
+ }
47
+ }
48
+ } finally {
49
+ await releaseRefreshLock(db, ctx.holder);
50
+ }
51
+ }
52
+ };
53
+ var worker_entry_default = {
54
+ /**
55
+ * Cron entry: start one rank-refresh Workflow instance per fire. Each instance re-ranks from the top;
56
+ * the checkpointing is within an instance (resume on crash), not across fires (a fire is a fresh full
57
+ * refresh). If a fire lands while a previous refresh is still running, the new instance takes no lock
58
+ * and exits immediately, so overlapping instances never write concurrently — see the lock acquisition
59
+ * in `run()` above.
60
+ */
61
+ async scheduled(_controller, env) {
62
+ await env.RANK_REFRESH.create();
63
+ } };
64
+ //#endregion
65
+ export { RankRefreshWorkflow, worker_entry_default as default };
@@ -0,0 +1,54 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { windowKeyAt } from "../window/schedule.js";
4
+ import { materializeSchedule } from "../config/config.js";
5
+ import { leaderboardDatabase } from "../data/tables.js";
6
+ import { refreshWindowRanks } from "./materialize.js";
7
+ import { pruneBoards } from "../retention/prune.js";
8
+ //#region src/rank/worker.ts
9
+ /** The boards a refresh pass must rank: only materialized ones. Live boards compute rank per request. */
10
+ function materializedBoards(config) {
11
+ return materializeSchedule(config) === void 0 ? [] : config.boards;
12
+ }
13
+ /**
14
+ * **Every contributor here is degraded, and none of them is load-bearing (#371).**
15
+ *
16
+ * The sweep runs first so the refresh never spends a chunk ranking rows about to be deleted — an
17
+ * efficiency, not a precondition. A board whose prune throws keeps its old rows a while longer and ranks
18
+ * correctly regardless, so the sweep failing is no reason to leave every board's ranks stale. And boards
19
+ * are independent of each other by construction: one board's entries, windows and ranks are its own, so a
20
+ * board that will not rank has no claim on any other board's pass.
21
+ *
22
+ * So one sick board costs its own line. What it must never do is read as a board that ranked nobody.
23
+ */
24
+ async function runRankPass(d1, config, now) {
25
+ const db = leaderboardDatabase(d1);
26
+ const pruned = await pruneBoards(db, config.boards, now);
27
+ const refreshed = [];
28
+ for (const board of materializedBoards(config)) {
29
+ const window = windowKeyAt(board.window, now);
30
+ let result;
31
+ try {
32
+ result = await refreshWindowRanks(db, board, window);
33
+ } catch {
34
+ refreshed.push({
35
+ state: "unavailable",
36
+ board: board.key,
37
+ window
38
+ });
39
+ continue;
40
+ }
41
+ refreshed.push({
42
+ state: "refreshed",
43
+ board: board.key,
44
+ window,
45
+ ...result
46
+ });
47
+ }
48
+ return {
49
+ pruned,
50
+ refreshed
51
+ };
52
+ }
53
+ //#endregion
54
+ export { materializedBoards, runRankPass };
@@ -0,0 +1,41 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { LeaderboardBoard } from "../config/config";
4
+ import type { LeaderboardDatabase } from "../data/tables";
5
+ export declare function pruneBoard(db: LeaderboardDatabase, board: LeaderboardBoard, now: Date): Promise<number>;
6
+ /**
7
+ * What a sweep over several boards deleted, and whether every board was swept (#371).
8
+ *
9
+ * The count sits behind the discriminant rather than beside a list of failures. A sweep that skipped a
10
+ * board deleted fewer rows than a sweep that did not, and `{ deleted: 4 }` cannot tell those apart —
11
+ * so `partial` spells the number differently, and a caller reaches it only by having been told the
12
+ * sweep was short.
13
+ */
14
+ export type PruneOutcome = {
15
+ /** Every board with a retention limit was swept. */
16
+ state: "pruned";
17
+ /** Rows deleted across them all. */
18
+ deleted: number;
19
+ } | {
20
+ /** Some boards were swept and at least one threw. */
21
+ state: "partial";
22
+ /** What the boards that were swept deleted — a total with a known hole in it. */
23
+ counted: {
24
+ deleted: number;
25
+ };
26
+ /** The key of every board whose prune threw. Non-empty, or this would be `pruned`. */
27
+ unpruned: string[];
28
+ };
29
+ /**
30
+ * Prune every board that configures a retention limit.
31
+ *
32
+ * **One board at a time (#371).** Retention is per board and boards are independent, so a board whose
33
+ * prune throws — a window key its config disagrees with, a D1 that stopped answering mid-sweep — used to
34
+ * discard every deletion the sweep had already made and take the rank pass down with it. It now costs its
35
+ * own entry and nothing else, and the boards it did not reach are named.
36
+ *
37
+ * **The guard takes no binding.** What a D1 write throws is throw-site context about somebody's database.
38
+ * The board key is this capability's own configuration and is the only thing anybody can act on.
39
+ */
40
+ export declare function pruneBoards(db: LeaderboardDatabase, boards: readonly LeaderboardBoard[], now: Date): Promise<PruneOutcome>;
41
+ //# sourceMappingURL=prune.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prune.d.ts","sourceRoot":"","sources":["../../src/retention/prune.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACzD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAwB1D,wBAAsB,UAAU,CAAC,EAAE,EAAE,mBAAmB,EAAE,KAAK,EAAE,gBAAgB,EAAE,GAAG,EAAE,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,CAuB7G;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,YAAY,GACpB;IACE,oDAAoD;IACpD,KAAK,EAAE,QAAQ,CAAC;IAChB,oCAAoC;IACpC,OAAO,EAAE,MAAM,CAAC;CACjB,GACD;IACE,qDAAqD;IACrD,KAAK,EAAE,SAAS,CAAC;IACjB,iFAAiF;IACjF,OAAO,EAAE;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IAC7B,sFAAsF;IACtF,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB,CAAC;AAEN;;;;;;;;;;GAUG;AACH,wBAAsB,WAAW,CAC/B,EAAE,EAAE,mBAAmB,EACvB,MAAM,EAAE,SAAS,gBAAgB,EAAE,EACnC,GAAG,EAAE,IAAI,GACR,OAAO,CAAC,YAAY,CAAC,CAWvB"}
@@ -0,0 +1,67 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { previousWindowKeys, windowKeyAt } from "../window/schedule.js";
4
+ import { entryStore } from "../entry/store.js";
5
+ //#region src/retention/prune.ts
6
+ /**
7
+ * Retention: how long closed windows live.
8
+ *
9
+ * This is the capability's plainest expression of principle 1. Nothing in the market offers unbounded
10
+ * leaderboard history — PlayFab meters retained versions and tier-gates them (its own tutorial defaults
11
+ * to keeping one), and Game Center holds an expired occurrence about 30 days and says outright it is not
12
+ * an archival store. Here, closed windows sit in the adopter's own D1 for exactly as long as they choose,
13
+ * in plain SQL they can join against their own tables. And the default is to keep **everything**: storage
14
+ * is never the cost driver (docs/costs.md — 3 GB at 10M players against a 10 GB cap), so nothing is
15
+ * deleted unless the adopter asks. Retention here is about data hygiene and compliance, not cost.
16
+ *
17
+ * Two ways to ask, mutually exclusive per board (validated in config):
18
+ *
19
+ * - `retain: N` — keep the newest N closed windows. A product limit ("browse the last 12 weeks").
20
+ * - `retainDays: N` — delete windows whose data is older than N days. A compliance limit.
21
+ *
22
+ * An all-time board never closes a window, so retention does not apply to it.
23
+ */
24
+ const DAY_MS = 864e5;
25
+ async function pruneBoard(db, board, now) {
26
+ if (board.window === void 0) return 0;
27
+ if (board.retain !== void 0) {
28
+ const closed = previousWindowKeys(board.window, now, board.retain);
29
+ const oldestKept = closed.length > 0 ? closed[closed.length - 1] : windowKeyAt(board.window, now);
30
+ return entryStore(db).pruneWindowsBefore(board.key, oldestKept);
31
+ }
32
+ if (board.retainDays !== void 0) {
33
+ const cutoff = windowKeyAt(board.window, /* @__PURE__ */ new Date(now.getTime() - board.retainDays * DAY_MS));
34
+ return entryStore(db).pruneWindowsBefore(board.key, cutoff);
35
+ }
36
+ return 0;
37
+ }
38
+ /**
39
+ * Prune every board that configures a retention limit.
40
+ *
41
+ * **One board at a time (#371).** Retention is per board and boards are independent, so a board whose
42
+ * prune throws — a window key its config disagrees with, a D1 that stopped answering mid-sweep — used to
43
+ * discard every deletion the sweep had already made and take the rank pass down with it. It now costs its
44
+ * own entry and nothing else, and the boards it did not reach are named.
45
+ *
46
+ * **The guard takes no binding.** What a D1 write throws is throw-site context about somebody's database.
47
+ * The board key is this capability's own configuration and is the only thing anybody can act on.
48
+ */
49
+ async function pruneBoards(db, boards, now) {
50
+ let deleted = 0;
51
+ const unpruned = [];
52
+ for (const board of boards) try {
53
+ deleted += await pruneBoard(db, board, now);
54
+ } catch {
55
+ unpruned.push(board.key);
56
+ }
57
+ return unpruned.length === 0 ? {
58
+ state: "pruned",
59
+ deleted
60
+ } : {
61
+ state: "partial",
62
+ counted: { deleted },
63
+ unpruned
64
+ };
65
+ }
66
+ //#endregion
67
+ export { pruneBoard, pruneBoards };
@@ -0,0 +1,13 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { type SeedSet } from "@pithy-sh/core/src/seed/seed";
4
+ /**
5
+ * A tiny demo board's worth of entries — the three canonical example users ({@link EXAMPLE_ADA} et al.)
6
+ * on an all-time board named `demo`. The `userId`s are the shared cast from `@pithy-sh/core`, so these
7
+ * scores belong to the same users `auth` seeds and `ledger`/`multiplayer` also reference: `pithy seed`
8
+ * fills a fresh backend with connected data, not isolated rows. Composed in only when the project turns
9
+ * on `seed.includeExamples` (`pithy.config.ts`), and only for `dev` and `staging` — an example fixture
10
+ * never targets production, regardless of that setting.
11
+ */
12
+ export declare const leaderboardExampleSeed: SeedSet;
13
+ //# sourceMappingURL=example.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"example.d.ts","sourceRoot":"","sources":["../../src/seeds/example.ts"],"names":[],"mappings":"AAIA,OAAO,EAA2B,KAAK,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAcrF;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,EAAE,OA6CnC,CAAC"}
@@ -0,0 +1,69 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { LeaderboardEntry } from "../data/entry.js";
4
+ import { LEADERBOARD_ENTRIES_TABLE } from "../data/tables.js";
5
+ import { EXAMPLE_ADA, EXAMPLE_ALAN, EXAMPLE_GRACE } from "@pithy-sh/core/src/seed/exampleIdentities";
6
+ import { d1SeedGroup, defineSeed } from "@pithy-sh/core/src/seed/seed";
7
+ //#region src/seeds/example.ts
8
+ /**
9
+ * Where the example set sorts among the whole project's seed registry. It runs after `auth` (100),
10
+ * whose example seeds the users these entries belong to, so the owning identities exist first — the
11
+ * order encodes that dependency, exactly like the migration registry. It need not line up with
12
+ * {@link LEADERBOARD_MIGRATION_ORDER} (a different registry, composed separately by `pithy seed`).
13
+ */
14
+ const LEADERBOARD_EXAMPLE_SEED_ORDER = 200;
15
+ const now = () => /* @__PURE__ */ new Date();
16
+ /**
17
+ * A tiny demo board's worth of entries — the three canonical example users ({@link EXAMPLE_ADA} et al.)
18
+ * on an all-time board named `demo`. The `userId`s are the shared cast from `@pithy-sh/core`, so these
19
+ * scores belong to the same users `auth` seeds and `ledger`/`multiplayer` also reference: `pithy seed`
20
+ * fills a fresh backend with connected data, not isolated rows. Composed in only when the project turns
21
+ * on `seed.includeExamples` (`pithy.config.ts`), and only for `dev` and `staging` — an example fixture
22
+ * never targets production, regardless of that setting.
23
+ */
24
+ const leaderboardExampleSeed = defineSeed({
25
+ name: "example",
26
+ order: LEADERBOARD_EXAMPLE_SEED_ORDER,
27
+ environments: ["dev", "staging"],
28
+ example: true,
29
+ d1: [d1SeedGroup("app", LEADERBOARD_ENTRIES_TABLE, LeaderboardEntry, [
30
+ {
31
+ id: 1,
32
+ boardId: "demo",
33
+ windowKey: "all",
34
+ userId: EXAMPLE_ADA.id,
35
+ score: 300,
36
+ achievedAt: now(),
37
+ submittedAt: now(),
38
+ visible: true,
39
+ hidden: false,
40
+ rank: null
41
+ },
42
+ {
43
+ id: 2,
44
+ boardId: "demo",
45
+ windowKey: "all",
46
+ userId: EXAMPLE_GRACE.id,
47
+ score: 250,
48
+ achievedAt: now(),
49
+ submittedAt: now(),
50
+ visible: true,
51
+ hidden: false,
52
+ rank: null
53
+ },
54
+ {
55
+ id: 3,
56
+ boardId: "demo",
57
+ windowKey: "all",
58
+ userId: EXAMPLE_ALAN.id,
59
+ score: 200,
60
+ achievedAt: now(),
61
+ submittedAt: now(),
62
+ visible: true,
63
+ hidden: false,
64
+ rank: null
65
+ }
66
+ ])]
67
+ });
68
+ //#endregion
69
+ export { leaderboardExampleSeed };
@@ -0,0 +1,49 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { D1Database } from "@cloudflare/workers-types";
4
+ import { type LeaderboardDatabase } from "../data/tables";
5
+ /**
6
+ * Read-your-own-writes across D1 read replication.
7
+ *
8
+ * D1 replicas "may be arbitrarily out of date" and Cloudflare publishes no staleness bound. On a
9
+ * leaderboard that lands exactly where a player notices: submit a score, read the board, and your own
10
+ * submission is missing. It reads as a lost write, not as replication lag, and it is the one
11
+ * inconsistency a ranking product cannot shrug off.
12
+ *
13
+ * The D1 Sessions API is the fix. Every query through a session is sequentially consistent with the
14
+ * session's bookmark, so threading a bookmark from a write to the player's next read guarantees they see
15
+ * themselves. The bookmark travels on {@link BOOKMARK_HEADER}: responses return the newest one, and a
16
+ * client echoes it back on the next request.
17
+ *
18
+ * The header is safe to expose and safe to ignore. It is an opaque replication watermark, not a
19
+ * credential — it carries no identity and grants nothing. A client that never echoes it is not broken,
20
+ * only unprotected against lag; a client that sends a stale or unparseable one is anchored no earlier
21
+ * than the write it names. Every route is authenticated regardless, so a bookmark cannot widen access.
22
+ */
23
+ /** The header a bookmark travels on, in both directions. */
24
+ export declare const BOOKMARK_HEADER = "x-pithy-d1-bookmark";
25
+ /**
26
+ * Where a session with no bookmark starts.
27
+ *
28
+ * Writes anchor at the primary — a submission must land there, and the bookmark it returns is what makes
29
+ * the player's next read see it. Reads with no bookmark are unconstrained and may serve from any replica:
30
+ * that is the point of replication, and a reader who has not written has nothing of their own to miss.
31
+ */
32
+ type SessionStart = "first-primary" | "first-unconstrained";
33
+ export interface LeaderboardSession {
34
+ /** The Kysely database bound to this session. Every query through it is sequentially consistent. */
35
+ db: LeaderboardDatabase;
36
+ /** The newest bookmark across this session's queries, or null if it ran none. */
37
+ bookmark(): string | null;
38
+ }
39
+ /**
40
+ * Open a D1 session anchored at `bookmark` when the client sent one, or at `start` when it did not.
41
+ *
42
+ * An unparseable or expired bookmark is D1's to reject, not ours to pre-validate: it is opaque to us, and
43
+ * guessing at its shape here would just be a second place to get it wrong.
44
+ */
45
+ export declare function leaderboardSession(d1: D1Database, bookmark: string | undefined, start: SessionStart): LeaderboardSession;
46
+ /** The bookmark a client echoed back, or undefined. */
47
+ export declare function readBookmark(headers: Headers): string | undefined;
48
+ export {};
49
+ //# sourceMappingURL=bookmark.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bookmark.d.ts","sourceRoot":"","sources":["../../src/session/bookmark.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAqB,MAAM,2BAA2B,CAAC;AAC/E,OAAO,EAAE,KAAK,mBAAmB,EAAuB,MAAM,gBAAgB,CAAC;AAE/E;;;;;;;;;;;;;;;;;GAiBG;AAEH,4DAA4D;AAC5D,eAAO,MAAM,eAAe,wBAAwB,CAAC;AAErD;;;;;;GAMG;AACH,KAAK,YAAY,GAAG,eAAe,GAAG,qBAAqB,CAAC;AAE5D,MAAM,WAAW,kBAAkB;IACjC,oGAAoG;IACpG,EAAE,EAAE,mBAAmB,CAAC;IACxB,iFAAiF;IACjF,QAAQ,IAAI,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,EAAE,EAAE,UAAU,EACd,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,KAAK,EAAE,YAAY,GAClB,kBAAkB,CAQpB;AAED,uDAAuD;AACvD,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAEjE"}
@@ -0,0 +1,43 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { leaderboardDatabase } from "../data/tables.js";
4
+ //#region src/session/bookmark.ts
5
+ /**
6
+ * Read-your-own-writes across D1 read replication.
7
+ *
8
+ * D1 replicas "may be arbitrarily out of date" and Cloudflare publishes no staleness bound. On a
9
+ * leaderboard that lands exactly where a player notices: submit a score, read the board, and your own
10
+ * submission is missing. It reads as a lost write, not as replication lag, and it is the one
11
+ * inconsistency a ranking product cannot shrug off.
12
+ *
13
+ * The D1 Sessions API is the fix. Every query through a session is sequentially consistent with the
14
+ * session's bookmark, so threading a bookmark from a write to the player's next read guarantees they see
15
+ * themselves. The bookmark travels on {@link BOOKMARK_HEADER}: responses return the newest one, and a
16
+ * client echoes it back on the next request.
17
+ *
18
+ * The header is safe to expose and safe to ignore. It is an opaque replication watermark, not a
19
+ * credential — it carries no identity and grants nothing. A client that never echoes it is not broken,
20
+ * only unprotected against lag; a client that sends a stale or unparseable one is anchored no earlier
21
+ * than the write it names. Every route is authenticated regardless, so a bookmark cannot widen access.
22
+ */
23
+ /** The header a bookmark travels on, in both directions. */
24
+ const BOOKMARK_HEADER = "x-pithy-d1-bookmark";
25
+ /**
26
+ * Open a D1 session anchored at `bookmark` when the client sent one, or at `start` when it did not.
27
+ *
28
+ * An unparseable or expired bookmark is D1's to reject, not ours to pre-validate: it is opaque to us, and
29
+ * guessing at its shape here would just be a second place to get it wrong.
30
+ */
31
+ function leaderboardSession(d1, bookmark, start) {
32
+ const session = d1.withSession(bookmark ?? start);
33
+ return {
34
+ db: leaderboardDatabase(session),
35
+ bookmark: () => session.getBookmark()
36
+ };
37
+ }
38
+ /** The bookmark a client echoed back, or undefined. */
39
+ function readBookmark(headers) {
40
+ return headers.get("x-pithy-d1-bookmark")?.trim() || void 0;
41
+ }
42
+ //#endregion
43
+ export { BOOKMARK_HEADER, leaderboardSession, readBookmark };
@@ -0,0 +1,7 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ /** This package's npm name — the join key against a release feed. */
4
+ export declare const PACKAGE_NAME = "@pithy-sh/leaderboard";
5
+ /** This package's version, stamped from its own package.json at generation time. */
6
+ export declare const PACKAGE_VERSION = "0.1.4";
7
+ //# sourceMappingURL=version.generated.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.generated.d.ts","sourceRoot":"","sources":["../src/version.generated.ts"],"names":[],"mappings":"AAWA,qEAAqE;AACrE,eAAO,MAAM,YAAY,0BAA0B,CAAC;AAEpD,oFAAoF;AACpF,eAAO,MAAM,eAAe,UAAU,CAAC"}
@@ -0,0 +1,9 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/version.generated.ts
4
+ /** This package's npm name — the join key against a release feed. */
5
+ const PACKAGE_NAME = "@pithy-sh/leaderboard";
6
+ /** This package's version, stamped from its own package.json at generation time. */
7
+ const PACKAGE_VERSION = "0.1.4";
8
+ //#endregion
9
+ export { PACKAGE_NAME, PACKAGE_VERSION };
@@ -0,0 +1,31 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Window keys. A board's `window` is a CRON expression; the window a score falls into is keyed by the
5
+ * instant that CRON last fired at or before the score. Omitting the schedule means one all-time window.
6
+ *
7
+ * CRON — rather than a fixed `daily|weekly|monthly|all-time` enum — is what lets a board align to the
8
+ * calendar. Apple caps leaderboard recurrence at 30 days and expresses it as a fixed duration, so a
9
+ * calendar month (28/29/30/31 days) is not expressible there and a calendar year is impossible; Google
10
+ * Play Games Services ships daily/weekly/all-time and no monthly at all. `0 0 1 * *` and `0 0 1 1 *`
11
+ * cost us nothing extra because `rank: { materialize: <cron> }` already needs a parser.
12
+ *
13
+ * Every derivation is UTC-anchored. The host's local timezone must never move a window boundary, or the
14
+ * same submission would key differently on two Workers.
15
+ */
16
+ /** The window key for a board with no schedule: one window, open forever. */
17
+ export declare const ALL_TIME_WINDOW = "all";
18
+ /**
19
+ * Validate a board's schedule at config time, so a typo fails at assembly rather than on the first
20
+ * submission. An expression that parses but never fires (`0 0 30 2 *` — February 30) is rejected too:
21
+ * it would strand every score with no window to key it to.
22
+ */
23
+ export declare function assertValidSchedule(schedule: string): void;
24
+ /** The key of the window `at` falls into: the ISO instant the board's CRON last fired at or before it. */
25
+ export declare function windowKeyAt(schedule: string | undefined, at: Date): string;
26
+ /**
27
+ * The keys of the `count` windows closed behind the one `at` falls into, newest first. Retention prunes
28
+ * everything older than the last of these.
29
+ */
30
+ export declare function previousWindowKeys(schedule: string | undefined, at: Date, count: number): string[];
31
+ //# sourceMappingURL=schedule.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schedule.d.ts","sourceRoot":"","sources":["../../src/window/schedule.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;GAYG;AAEH,6EAA6E;AAC7E,eAAO,MAAM,eAAe,QAAQ,CAAC;AAuDrC;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAQ1D;AAED,0GAA0G;AAC1G,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAAE,EAAE,EAAE,IAAI,GAAG,MAAM,CAS1E;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAYlG"}
@@ -0,0 +1,108 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import { LeaderboardInvalidScheduleError } from "../error/errors.js";
4
+ import { Cron } from "croner";
5
+ //#region src/window/schedule.ts
6
+ /**
7
+ * Window keys. A board's `window` is a CRON expression; the window a score falls into is keyed by the
8
+ * instant that CRON last fired at or before the score. Omitting the schedule means one all-time window.
9
+ *
10
+ * CRON — rather than a fixed `daily|weekly|monthly|all-time` enum — is what lets a board align to the
11
+ * calendar. Apple caps leaderboard recurrence at 30 days and expresses it as a fixed duration, so a
12
+ * calendar month (28/29/30/31 days) is not expressible there and a calendar year is impossible; Google
13
+ * Play Games Services ships daily/weekly/all-time and no monthly at all. `0 0 1 * *` and `0 0 1 1 *`
14
+ * cost us nothing extra because `rank: { materialize: <cron> }` already needs a parser.
15
+ *
16
+ * Every derivation is UTC-anchored. The host's local timezone must never move a window boundary, or the
17
+ * same submission would key differently on two Workers.
18
+ */
19
+ /** The window key for a board with no schedule: one window, open forever. */
20
+ const ALL_TIME_WINDOW = "all";
21
+ /**
22
+ * Lookback rungs for {@link lastFireAtOrBefore}, smallest first.
23
+ *
24
+ * croner can only walk *forward* (`nextRun`); its `previousRun` reports a live job's last execution, not
25
+ * a historical period start, so finding the window a past instant falls into means searching back. The
26
+ * ladder keeps that search cheap for every cadence: a rung is tried only if the finer one found no fire,
27
+ * so a per-minute board settles on the first rung after one step and a yearly board reaches the last rung
28
+ * having walked one. That bounds the forward walk to roughly one period per rung instead of letting a
29
+ * frequent board enumerate a year of fires. The final rung spans four years: `0 0 29 2 *` — a leap-day
30
+ * board — only fires when February has 29 days.
31
+ */
32
+ const MINUTE_MS = 6e4;
33
+ const HOUR_MS = 60 * MINUTE_MS;
34
+ const DAY_MS = 24 * HOUR_MS;
35
+ const LOOKBACK_LADDER_MS = [
36
+ MINUTE_MS,
37
+ HOUR_MS,
38
+ DAY_MS,
39
+ 8 * DAY_MS,
40
+ 40 * DAY_MS,
41
+ 400 * DAY_MS,
42
+ 1500 * DAY_MS
43
+ ];
44
+ /** How far ahead {@link assertValidSchedule} looks for a fire before calling an expression dead. */
45
+ const NEVER_FIRES_HORIZON = LOOKBACK_LADDER_MS[LOOKBACK_LADDER_MS.length - 1];
46
+ function compile(schedule) {
47
+ try {
48
+ return new Cron(schedule, { timezone: "UTC" });
49
+ } catch (cause) {
50
+ throw new LeaderboardInvalidScheduleError({ detail: `Board schedule ${JSON.stringify(schedule)} is not a valid CRON expression.` }, { cause });
51
+ }
52
+ }
53
+ /**
54
+ * The latest fire at or before `at`, or null if the ladder's deepest rung found none.
55
+ *
56
+ * An instant exactly on a boundary belongs to the window it opens, not the one it closes — hence
57
+ * `<= target` rather than `<`. Off by one here and a score landing precisely on the boundary files into
58
+ * the window that just closed.
59
+ */
60
+ function lastFireAtOrBefore(cron, at) {
61
+ const target = at.getTime();
62
+ for (const lookback of LOOKBACK_LADDER_MS) {
63
+ let candidate = null;
64
+ let cursor = cron.nextRun(new Date(target - lookback));
65
+ while (cursor && cursor.getTime() <= target) {
66
+ candidate = cursor;
67
+ cursor = cron.nextRun(cursor);
68
+ }
69
+ if (candidate) return candidate;
70
+ }
71
+ return null;
72
+ }
73
+ /**
74
+ * Validate a board's schedule at config time, so a typo fails at assembly rather than on the first
75
+ * submission. An expression that parses but never fires (`0 0 30 2 *` — February 30) is rejected too:
76
+ * it would strand every score with no window to key it to.
77
+ */
78
+ function assertValidSchedule(schedule) {
79
+ if (!compile(schedule).nextRun(new Date(Date.now() - NEVER_FIRES_HORIZON))) throw new LeaderboardInvalidScheduleError({
80
+ message: "That board's window schedule never fires.",
81
+ detail: `Board schedule ${JSON.stringify(schedule)} parses but never fires, so no window could ever open.`
82
+ });
83
+ }
84
+ /** The key of the window `at` falls into: the ISO instant the board's CRON last fired at or before it. */
85
+ function windowKeyAt(schedule, at) {
86
+ if (schedule === void 0) return "all";
87
+ const start = lastFireAtOrBefore(compile(schedule), at);
88
+ if (!start) throw new LeaderboardInvalidScheduleError({ detail: `Board schedule ${JSON.stringify(schedule)} has no fire at or before ${at.toISOString()}.` });
89
+ return start.toISOString();
90
+ }
91
+ /**
92
+ * The keys of the `count` windows closed behind the one `at` falls into, newest first. Retention prunes
93
+ * everything older than the last of these.
94
+ */
95
+ function previousWindowKeys(schedule, at, count) {
96
+ if (schedule === void 0 || count <= 0) return [];
97
+ const cron = compile(schedule);
98
+ const keys = [];
99
+ let cursor = lastFireAtOrBefore(cron, at);
100
+ for (let i = 0; i < count; i++) {
101
+ if (!cursor) break;
102
+ cursor = lastFireAtOrBefore(cron, /* @__PURE__ */ new Date(cursor.getTime() - 1));
103
+ if (cursor) keys.push(cursor.toISOString());
104
+ }
105
+ return keys;
106
+ }
107
+ //#endregion
108
+ export { ALL_TIME_WINDOW, assertValidSchedule, previousWindowKeys, windowKeyAt };