@pithy-sh/leaderboard 0.1.2 → 0.1.3

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