@pithy-sh/leaderboard 0.1.0

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.
@@ -0,0 +1,104 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { LeaderboardBoard } from "../config/config";
5
+ import type { LeaderboardDatabase } from "../data/tables";
6
+ import { entryStore } from "../entry/store";
7
+ import { previousWindowKeys, windowKeyAt } from "../window/schedule";
8
+
9
+ /**
10
+ * Retention: how long closed windows live.
11
+ *
12
+ * This is the capability's plainest expression of principle 1. Nothing in the market offers unbounded
13
+ * leaderboard history — PlayFab meters retained versions and tier-gates them (its own tutorial defaults
14
+ * to keeping one), and Game Center holds an expired occurrence about 30 days and says outright it is not
15
+ * an archival store. Here, closed windows sit in the adopter's own D1 for exactly as long as they choose,
16
+ * in plain SQL they can join against their own tables. And the default is to keep **everything**: storage
17
+ * is never the cost driver (docs/costs.md — 3 GB at 10M players against a 10 GB cap), so nothing is
18
+ * deleted unless the adopter asks. Retention here is about data hygiene and compliance, not cost.
19
+ *
20
+ * Two ways to ask, mutually exclusive per board (validated in config):
21
+ *
22
+ * - `retain: N` — keep the newest N closed windows. A product limit ("browse the last 12 weeks").
23
+ * - `retainDays: N` — delete windows whose data is older than N days. A compliance limit.
24
+ *
25
+ * An all-time board never closes a window, so retention does not apply to it.
26
+ */
27
+ const DAY_MS = 24 * 60 * 60 * 1000;
28
+
29
+ export async function pruneBoard(db: LeaderboardDatabase, board: LeaderboardBoard, now: Date): Promise<number> {
30
+ if (board.window === undefined) return 0;
31
+
32
+ if (board.retain !== undefined) {
33
+ // Keep the open window plus the newest `retain` closed ones. The kept set is a contiguous newest
34
+ // suffix, so "delete everything not kept" is exactly "delete everything older than the oldest kept
35
+ // window" — a cutoff delete with two bound parameters, rather than a `NOT IN` list that would blow
36
+ // D1's 100-bound-parameter cap once `retain` reaches ~98 (a daily board keeping a year is retain 365).
37
+ const closed = previousWindowKeys(board.window, now, board.retain);
38
+ const oldestKept = closed.length > 0 ? (closed[closed.length - 1] as string) : windowKeyAt(board.window, now);
39
+ return entryStore(db).pruneWindowsBefore(board.key, oldestKept);
40
+ }
41
+
42
+ if (board.retainDays !== undefined) {
43
+ // Keep the window that was open `retainDays` ago and everything newer: that window is the oldest one
44
+ // whose tail is still within the retention horizon. Its key is the cutoff — delete windows before it.
45
+ // Window keys are ISO instants, so a lexicographic `<` is a chronological one.
46
+ const cutoff = windowKeyAt(board.window, new Date(now.getTime() - board.retainDays * DAY_MS));
47
+ return entryStore(db).pruneWindowsBefore(board.key, cutoff);
48
+ }
49
+
50
+ // Neither limit set: keep everything. The default.
51
+ return 0;
52
+ }
53
+
54
+ /**
55
+ * What a sweep over several boards deleted, and whether every board was swept (#371).
56
+ *
57
+ * The count sits behind the discriminant rather than beside a list of failures. A sweep that skipped a
58
+ * board deleted fewer rows than a sweep that did not, and `{ deleted: 4 }` cannot tell those apart —
59
+ * so `partial` spells the number differently, and a caller reaches it only by having been told the
60
+ * sweep was short.
61
+ */
62
+ export type PruneOutcome =
63
+ | {
64
+ /** Every board with a retention limit was swept. */
65
+ state: "pruned";
66
+ /** Rows deleted across them all. */
67
+ deleted: number;
68
+ }
69
+ | {
70
+ /** Some boards were swept and at least one threw. */
71
+ state: "partial";
72
+ /** What the boards that were swept deleted — a total with a known hole in it. */
73
+ counted: { deleted: number };
74
+ /** The key of every board whose prune threw. Non-empty, or this would be `pruned`. */
75
+ unpruned: string[];
76
+ };
77
+
78
+ /**
79
+ * Prune every board that configures a retention limit.
80
+ *
81
+ * **One board at a time (#371).** Retention is per board and boards are independent, so a board whose
82
+ * prune throws — a window key its config disagrees with, a D1 that stopped answering mid-sweep — used to
83
+ * discard every deletion the sweep had already made and take the rank pass down with it. It now costs its
84
+ * own entry and nothing else, and the boards it did not reach are named.
85
+ *
86
+ * **The guard takes no binding.** What a D1 write throws is throw-site context about somebody's database.
87
+ * The board key is this capability's own configuration and is the only thing anybody can act on.
88
+ */
89
+ export async function pruneBoards(
90
+ db: LeaderboardDatabase,
91
+ boards: readonly LeaderboardBoard[],
92
+ now: Date,
93
+ ): Promise<PruneOutcome> {
94
+ let deleted = 0;
95
+ const unpruned: string[] = [];
96
+ for (const board of boards) {
97
+ try {
98
+ deleted += await pruneBoard(db, board, now);
99
+ } catch {
100
+ unpruned.push(board.key);
101
+ }
102
+ }
103
+ return unpruned.length === 0 ? { state: "pruned", deleted } : { state: "partial", counted: { deleted }, unpruned };
104
+ }
@@ -0,0 +1,72 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { EXAMPLE_ADA, EXAMPLE_ALAN, EXAMPLE_GRACE } from "@pithy-sh/core/src/seed/exampleIdentities";
5
+ import { d1SeedGroup, defineSeed, type SeedSet } from "@pithy-sh/core/src/seed/seed";
6
+ import { LeaderboardEntry } from "../data/entry";
7
+ import { LEADERBOARD_ENTRIES_TABLE } from "../data/tables";
8
+
9
+ /**
10
+ * Where the example set sorts among the whole project's seed registry. It runs after `auth` (100),
11
+ * whose example seeds the users these entries belong to, so the owning identities exist first — the
12
+ * order encodes that dependency, exactly like the migration registry. It need not line up with
13
+ * {@link LEADERBOARD_MIGRATION_ORDER} (a different registry, composed separately by `pithy seed`).
14
+ */
15
+ const LEADERBOARD_EXAMPLE_SEED_ORDER = 200;
16
+
17
+ const now = () => new Date();
18
+
19
+ /**
20
+ * A tiny demo board's worth of entries — the three canonical example users ({@link EXAMPLE_ADA} et al.)
21
+ * on an all-time board named `demo`. The `userId`s are the shared cast from `@pithy-sh/core`, so these
22
+ * scores belong to the same users `auth` seeds and `ledger`/`multiplayer` also reference: `pithy seed`
23
+ * fills a fresh backend with connected data, not isolated rows. Composed in only when the project turns
24
+ * on `seed.includeExamples` (`pithy.config.ts`), and only for `dev` and `staging` — an example fixture
25
+ * never targets production, regardless of that setting.
26
+ */
27
+ export const leaderboardExampleSeed: SeedSet = defineSeed({
28
+ name: "example",
29
+ order: LEADERBOARD_EXAMPLE_SEED_ORDER,
30
+ environments: ["dev", "staging"],
31
+ example: true,
32
+ d1: [
33
+ d1SeedGroup("app", LEADERBOARD_ENTRIES_TABLE, LeaderboardEntry, [
34
+ {
35
+ id: 1,
36
+ boardId: "demo",
37
+ windowKey: "all",
38
+ userId: EXAMPLE_ADA.id,
39
+ score: 300,
40
+ achievedAt: now(),
41
+ submittedAt: now(),
42
+ visible: true,
43
+ hidden: false,
44
+ rank: null,
45
+ },
46
+ {
47
+ id: 2,
48
+ boardId: "demo",
49
+ windowKey: "all",
50
+ userId: EXAMPLE_GRACE.id,
51
+ score: 250,
52
+ achievedAt: now(),
53
+ submittedAt: now(),
54
+ visible: true,
55
+ hidden: false,
56
+ rank: null,
57
+ },
58
+ {
59
+ id: 3,
60
+ boardId: "demo",
61
+ windowKey: "all",
62
+ userId: EXAMPLE_ALAN.id,
63
+ score: 200,
64
+ achievedAt: now(),
65
+ submittedAt: now(),
66
+ visible: true,
67
+ hidden: false,
68
+ rank: null,
69
+ },
70
+ ]),
71
+ ],
72
+ });
@@ -0,0 +1,68 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { D1Database, D1DatabaseSession } from "@cloudflare/workers-types";
5
+ import { type LeaderboardDatabase, leaderboardDatabase } from "../data/tables";
6
+
7
+ /**
8
+ * Read-your-own-writes across D1 read replication.
9
+ *
10
+ * D1 replicas "may be arbitrarily out of date" and Cloudflare publishes no staleness bound. On a
11
+ * leaderboard that lands exactly where a player notices: submit a score, read the board, and your own
12
+ * submission is missing. It reads as a lost write, not as replication lag, and it is the one
13
+ * inconsistency a ranking product cannot shrug off.
14
+ *
15
+ * The D1 Sessions API is the fix. Every query through a session is sequentially consistent with the
16
+ * session's bookmark, so threading a bookmark from a write to the player's next read guarantees they see
17
+ * themselves. The bookmark travels on {@link BOOKMARK_HEADER}: responses return the newest one, and a
18
+ * client echoes it back on the next request.
19
+ *
20
+ * The header is safe to expose and safe to ignore. It is an opaque replication watermark, not a
21
+ * credential — it carries no identity and grants nothing. A client that never echoes it is not broken,
22
+ * only unprotected against lag; a client that sends a stale or unparseable one is anchored no earlier
23
+ * than the write it names. Every route is authenticated regardless, so a bookmark cannot widen access.
24
+ */
25
+
26
+ /** The header a bookmark travels on, in both directions. */
27
+ export const BOOKMARK_HEADER = "x-pithy-d1-bookmark";
28
+
29
+ /**
30
+ * Where a session with no bookmark starts.
31
+ *
32
+ * Writes anchor at the primary — a submission must land there, and the bookmark it returns is what makes
33
+ * the player's next read see it. Reads with no bookmark are unconstrained and may serve from any replica:
34
+ * that is the point of replication, and a reader who has not written has nothing of their own to miss.
35
+ */
36
+ type SessionStart = "first-primary" | "first-unconstrained";
37
+
38
+ export interface LeaderboardSession {
39
+ /** The Kysely database bound to this session. Every query through it is sequentially consistent. */
40
+ db: LeaderboardDatabase;
41
+ /** The newest bookmark across this session's queries, or null if it ran none. */
42
+ bookmark(): string | null;
43
+ }
44
+
45
+ /**
46
+ * Open a D1 session anchored at `bookmark` when the client sent one, or at `start` when it did not.
47
+ *
48
+ * An unparseable or expired bookmark is D1's to reject, not ours to pre-validate: it is opaque to us, and
49
+ * guessing at its shape here would just be a second place to get it wrong.
50
+ */
51
+ export function leaderboardSession(
52
+ d1: D1Database,
53
+ bookmark: string | undefined,
54
+ start: SessionStart,
55
+ ): LeaderboardSession {
56
+ const session: D1DatabaseSession = d1.withSession(bookmark ?? start);
57
+ return {
58
+ // kysely-d1 drives the database through `prepare` alone, which a session provides — so a session
59
+ // stands in for the binding without the dialect knowing the difference.
60
+ db: leaderboardDatabase(session as unknown as D1Database),
61
+ bookmark: () => session.getBookmark(),
62
+ };
63
+ }
64
+
65
+ /** The bookmark a client echoed back, or undefined. */
66
+ export function readBookmark(headers: Headers): string | undefined {
67
+ return headers.get(BOOKMARK_HEADER)?.trim() || undefined;
68
+ }
@@ -0,0 +1,16 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // GENERATED by scripts/stampVersions.ts — do not edit by hand. Regenerate with `bun run stamp-versions`.
5
+ //
6
+ // A Worker cannot read its own package.json, so this is how @pithy-sh/leaderboard knows its own version at
7
+ // runtime. The capability attaches it, and `GET /control-plane/manifest` reports it per capability —
8
+ // which is what answers "should this project upgrade" and "is this customer exposed to what we just
9
+ // fixed". Those questions are only answerable per module, because a project composes some capabilities
10
+ // and not others.
11
+
12
+ /** This package's npm name — the join key against a release feed. */
13
+ export const PACKAGE_NAME = "@pithy-sh/leaderboard";
14
+
15
+ /** This package's version, stamped from its own package.json at generation time. */
16
+ export const PACKAGE_VERSION = "0.1.0";
@@ -0,0 +1,120 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { Cron } from "croner";
5
+ import { LeaderboardInvalidScheduleError } from "../error/errors";
6
+
7
+ /**
8
+ * Window keys. A board's `window` is a CRON expression; the window a score falls into is keyed by the
9
+ * instant that CRON last fired at or before the score. Omitting the schedule means one all-time window.
10
+ *
11
+ * CRON — rather than a fixed `daily|weekly|monthly|all-time` enum — is what lets a board align to the
12
+ * calendar. Apple caps leaderboard recurrence at 30 days and expresses it as a fixed duration, so a
13
+ * calendar month (28/29/30/31 days) is not expressible there and a calendar year is impossible; Google
14
+ * Play Games Services ships daily/weekly/all-time and no monthly at all. `0 0 1 * *` and `0 0 1 1 *`
15
+ * cost us nothing extra because `rank: { materialize: <cron> }` already needs a parser.
16
+ *
17
+ * Every derivation is UTC-anchored. The host's local timezone must never move a window boundary, or the
18
+ * same submission would key differently on two Workers.
19
+ */
20
+
21
+ /** The window key for a board with no schedule: one window, open forever. */
22
+ export const ALL_TIME_WINDOW = "all";
23
+
24
+ /**
25
+ * Lookback rungs for {@link lastFireAtOrBefore}, smallest first.
26
+ *
27
+ * croner can only walk *forward* (`nextRun`); its `previousRun` reports a live job's last execution, not
28
+ * a historical period start, so finding the window a past instant falls into means searching back. The
29
+ * ladder keeps that search cheap for every cadence: a rung is tried only if the finer one found no fire,
30
+ * so a per-minute board settles on the first rung after one step and a yearly board reaches the last rung
31
+ * having walked one. That bounds the forward walk to roughly one period per rung instead of letting a
32
+ * frequent board enumerate a year of fires. The final rung spans four years: `0 0 29 2 *` — a leap-day
33
+ * board — only fires when February has 29 days.
34
+ */
35
+ const MINUTE_MS = 60_000;
36
+ const HOUR_MS = 60 * MINUTE_MS;
37
+ const DAY_MS = 24 * HOUR_MS;
38
+ const LOOKBACK_LADDER_MS = [MINUTE_MS, HOUR_MS, DAY_MS, 8 * DAY_MS, 40 * DAY_MS, 400 * DAY_MS, 1500 * DAY_MS];
39
+
40
+ /** How far ahead {@link assertValidSchedule} looks for a fire before calling an expression dead. */
41
+ const NEVER_FIRES_HORIZON = LOOKBACK_LADDER_MS[LOOKBACK_LADDER_MS.length - 1] as number;
42
+
43
+ function compile(schedule: string): Cron {
44
+ try {
45
+ // `timezone: "UTC"` is the anchor: without it croner resolves against the host zone and a board
46
+ // boundary would drift by the deployment's offset.
47
+ return new Cron(schedule, { timezone: "UTC" });
48
+ } catch (cause) {
49
+ throw new LeaderboardInvalidScheduleError(
50
+ { detail: `Board schedule ${JSON.stringify(schedule)} is not a valid CRON expression.` },
51
+ { cause },
52
+ );
53
+ }
54
+ }
55
+
56
+ /**
57
+ * The latest fire at or before `at`, or null if the ladder's deepest rung found none.
58
+ *
59
+ * An instant exactly on a boundary belongs to the window it opens, not the one it closes — hence
60
+ * `<= target` rather than `<`. Off by one here and a score landing precisely on the boundary files into
61
+ * the window that just closed.
62
+ */
63
+ function lastFireAtOrBefore(cron: Cron, at: Date): Date | null {
64
+ const target = at.getTime();
65
+ for (const lookback of LOOKBACK_LADDER_MS) {
66
+ let candidate: Date | null = null;
67
+ let cursor: Date | null = cron.nextRun(new Date(target - lookback));
68
+ while (cursor && cursor.getTime() <= target) {
69
+ candidate = cursor;
70
+ cursor = cron.nextRun(cursor);
71
+ }
72
+ if (candidate) return candidate;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * Validate a board's schedule at config time, so a typo fails at assembly rather than on the first
79
+ * submission. An expression that parses but never fires (`0 0 30 2 *` — February 30) is rejected too:
80
+ * it would strand every score with no window to key it to.
81
+ */
82
+ export function assertValidSchedule(schedule: string): void {
83
+ const cron = compile(schedule);
84
+ if (!cron.nextRun(new Date(Date.now() - NEVER_FIRES_HORIZON))) {
85
+ throw new LeaderboardInvalidScheduleError({
86
+ message: "That board's window schedule never fires.",
87
+ detail: `Board schedule ${JSON.stringify(schedule)} parses but never fires, so no window could ever open.`,
88
+ });
89
+ }
90
+ }
91
+
92
+ /** The key of the window `at` falls into: the ISO instant the board's CRON last fired at or before it. */
93
+ export function windowKeyAt(schedule: string | undefined, at: Date): string {
94
+ if (schedule === undefined) return ALL_TIME_WINDOW;
95
+ const start = lastFireAtOrBefore(compile(schedule), at);
96
+ if (!start) {
97
+ throw new LeaderboardInvalidScheduleError({
98
+ detail: `Board schedule ${JSON.stringify(schedule)} has no fire at or before ${at.toISOString()}.`,
99
+ });
100
+ }
101
+ return start.toISOString();
102
+ }
103
+
104
+ /**
105
+ * The keys of the `count` windows closed behind the one `at` falls into, newest first. Retention prunes
106
+ * everything older than the last of these.
107
+ */
108
+ export function previousWindowKeys(schedule: string | undefined, at: Date, count: number): string[] {
109
+ if (schedule === undefined || count <= 0) return [];
110
+ const cron = compile(schedule);
111
+ const keys: string[] = [];
112
+ let cursor = lastFireAtOrBefore(cron, at);
113
+ for (let i = 0; i < count; i++) {
114
+ if (!cursor) break;
115
+ // Step one millisecond behind this window's start to land inside the window before it.
116
+ cursor = lastFireAtOrBefore(cron, new Date(cursor.getTime() - 1));
117
+ if (cursor) keys.push(cursor.toISOString());
118
+ }
119
+ return keys;
120
+ }