@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,22 @@
1
+ import type { LeaderboardBoard } from "../config/config";
2
+ import { type LeaderboardDatabase } from "../data/tables";
3
+ /**
4
+ * The board drift guard.
5
+ *
6
+ * `store`, `direction`, `aggregation`, and `window` are create-time and immutable on every vendor
7
+ * surveyed, and for good reason: each one is the lens through which stored scores are read, so changing
8
+ * one reinterprets data rather than reconfiguring behavior. Flip `direction` and last place becomes
9
+ * first. Switch `best` to `sum` and the number in the column stops meaning what it meant. Change `window`
10
+ * and new scores key into windows that do not line up with the stored ones. Move a board to a different
11
+ * `store` and its scores live in a different place entirely.
12
+ *
13
+ * Pithy cannot enforce that in the type system, because a board is config the adopter edits freely and
14
+ * redeploys. So the first entry on a board records those fields, and every later write checks against the
15
+ * record. The failure is loud and immediate instead of a silently corrupted board.
16
+ *
17
+ * This is the answer to the issue's open question: a board definition is *not* migratable. There is no
18
+ * `pithy migrate` story for a board, because there is no safe automatic reinterpretation of the scores
19
+ * already stored. Changing one of these fields means a new board key.
20
+ */
21
+ export declare function assertBoardDefinition(db: LeaderboardDatabase, board: LeaderboardBoard, now: Date): Promise<void>;
22
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/board/registry.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAEzD,OAAO,EAA4B,KAAK,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAGpF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,qBAAqB,CACzC,EAAE,EAAE,mBAAmB,EACvB,KAAK,EAAE,gBAAgB,EACvB,GAAG,EAAE,IAAI,GACR,OAAO,CAAC,IAAI,CAAC,CA+Df"}
@@ -0,0 +1,52 @@
1
+ import { LeaderboardBoardImmutableError } from "../error/errors.js";
2
+ import { LeaderboardBoardRecord } from "../data/boardRecord.js";
3
+ import { LEADERBOARD_BOARDS_TABLE } from "../data/tables.js";
4
+ //#region src/board/registry.ts
5
+ /**
6
+ * The board drift guard.
7
+ *
8
+ * `store`, `direction`, `aggregation`, and `window` are create-time and immutable on every vendor
9
+ * surveyed, and for good reason: each one is the lens through which stored scores are read, so changing
10
+ * one reinterprets data rather than reconfiguring behavior. Flip `direction` and last place becomes
11
+ * first. Switch `best` to `sum` and the number in the column stops meaning what it meant. Change `window`
12
+ * and new scores key into windows that do not line up with the stored ones. Move a board to a different
13
+ * `store` and its scores live in a different place entirely.
14
+ *
15
+ * Pithy cannot enforce that in the type system, because a board is config the adopter edits freely and
16
+ * redeploys. So the first entry on a board records those fields, and every later write checks against the
17
+ * record. The failure is loud and immediate instead of a silently corrupted board.
18
+ *
19
+ * This is the answer to the issue's open question: a board definition is *not* migratable. There is no
20
+ * `pithy migrate` story for a board, because there is no safe automatic reinterpretation of the scores
21
+ * already stored. Changing one of these fields means a new board key.
22
+ */
23
+ async function assertBoardDefinition(db, board, now) {
24
+ let recorded = await db.selectFrom(LEADERBOARD_BOARDS_TABLE).selectAll().where("boardKey", "=", board.key).executeTakeFirst();
25
+ if (!recorded) {
26
+ const row = LeaderboardBoardRecord.encode({
27
+ id: 0,
28
+ boardKey: board.key,
29
+ store: board.store,
30
+ direction: board.direction,
31
+ aggregation: board.aggregation,
32
+ window: board.window ?? null,
33
+ createdAt: now
34
+ });
35
+ delete row.id;
36
+ await db.insertInto(LEADERBOARD_BOARDS_TABLE).values(row).onConflict((oc) => oc.column("boardKey").doNothing()).execute();
37
+ recorded = await db.selectFrom(LEADERBOARD_BOARDS_TABLE).selectAll().where("boardKey", "=", board.key).executeTakeFirst();
38
+ if (!recorded) throw new LeaderboardBoardImmutableError({
39
+ message: "That board's definition could not be confirmed. Retry.",
40
+ detail: `Board "${board.key}" record vanished immediately after insert; a concurrent delete is the likely cause.`
41
+ });
42
+ }
43
+ const previous = LeaderboardBoardRecord.parse(recorded);
44
+ const drifted = [];
45
+ if (previous.store !== board.store) drifted.push(`store ${previous.store} → ${board.store}`);
46
+ if (previous.direction !== board.direction) drifted.push(`direction ${previous.direction} → ${board.direction}`);
47
+ if (previous.aggregation !== board.aggregation) drifted.push(`aggregation ${previous.aggregation} → ${board.aggregation}`);
48
+ if ((previous.window ?? null) !== (board.window ?? null)) drifted.push(`window ${previous.window ?? "all-time"} → ${board.window ?? "all-time"}`);
49
+ if (drifted.length > 0) throw new LeaderboardBoardImmutableError({ detail: `Board "${board.key}" changed after recording entries: ${drifted.join(", ")}.` });
50
+ }
51
+ //#endregion
52
+ export { assertBoardDefinition };
@@ -0,0 +1,43 @@
1
+ import { type Capability } from "@pithy-sh/core/src/capability/capability";
2
+ import { LeaderboardConfig, type LeaderboardConfigInput } from "./config/config";
3
+ /**
4
+ * Where leaderboard's migrations sort in the app database. Unique per database; the registry composes
5
+ * keys like `0400_leaderboard_0001_entries`. Sits after media (300) and audit (250).
6
+ */
7
+ export declare const LEADERBOARD_MIGRATION_ORDER = 400;
8
+ export type LeaderboardOptions = LeaderboardConfigInput & {
9
+ /** Mount the routes somewhere other than `/leaderboard`. */
10
+ basePath?: string;
11
+ };
12
+ export interface LeaderboardCapability extends Capability {
13
+ leaderboardConfig: LeaderboardConfig;
14
+ }
15
+ /**
16
+ * The leaderboard capability: submit a score, read the standings, read your own rank — across windows.
17
+ *
18
+ * Fully optional. Config, migrations, routes, and bindings arrive only on `pithy add leaderboard`, and
19
+ * `pithy remove leaderboard` is the clean inverse.
20
+ *
21
+ * One store, always: D1. There is no engine flag, because Durable Objects do not fix what a leaderboard
22
+ * actually strains against. A DO bills rows exactly as D1 does, so a rank scan inside one costs the same;
23
+ * it adds request and duration billing D1 does not have; and each object is single-threaded with a soft
24
+ * 1,000 req/s ceiling and the same 10 GB cap, so it does not relieve hot-board serialization either.
25
+ * Scale is a cadence dial (`rank: { materialize }`), which is pure D1. A DO earns its place only when
26
+ * `live: true` ships — as a WebSocket push layer *over* D1, which is a latency play, not a store.
27
+ *
28
+ * `dependsOn` is deliberately empty. Auth is not a peer capability but a seam: the routes read
29
+ * `c.var.auth` through core's `AuthContext`, so without `@pithy-sh/auth` installed every route is denied
30
+ * rather than open. That is the right failure and it needs no dependency edge.
31
+ */
32
+ export declare function leaderboard(options?: LeaderboardOptions): LeaderboardCapability;
33
+ export declare function isLeaderboardCapability(capability: Capability): capability is LeaderboardCapability;
34
+ /**
35
+ * Whether this configuration needs the rank worker deployed.
36
+ *
37
+ * Two things need it: materialized rank (the refresh), and configured retention (the prune). Retention
38
+ * now defaults to keep-all, so a windowed board only needs the worker if it actually sets `retain` or
39
+ * `retainDays` — a board that keeps everything has nothing to prune. A live, keep-all board set needs no
40
+ * worker at all.
41
+ */
42
+ export declare function needsRankWorker(config: LeaderboardConfig): boolean;
43
+ //# sourceMappingURL=capability.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capability.d.ts","sourceRoot":"","sources":["../src/capability.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,UAAU,EAAoB,MAAM,0CAA0C,CAAC;AAE7F,OAAO,EAAE,iBAAiB,EAAE,KAAK,sBAAsB,EAAuB,MAAM,iBAAiB,CAAC;AAOtG;;;GAGG;AACH,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAE/C,MAAM,MAAM,kBAAkB,GAAG,sBAAsB,GAAG;IACxD,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,WAAW,qBAAsB,SAAQ,UAAU;IACvD,iBAAiB,EAAE,iBAAiB,CAAC;CACtC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,WAAW,CAAC,OAAO,GAAE,kBAAmC,GAAG,qBAAqB,CA8B/F;AAED,wBAAgB,uBAAuB,CAAC,UAAU,EAAE,UAAU,GAAG,UAAU,IAAI,qBAAqB,CAEnG;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,iBAAiB,GAAG,OAAO,CAGlE"}
@@ -0,0 +1,73 @@
1
+ import { LeaderboardConfig, materializeSchedule } from "./config/config.js";
2
+ import { leaderboardTables } from "./data/tables.js";
3
+ import { registerLeaderboardRoutes } from "./http/routes.js";
4
+ import { leaderboard_0001_entries } from "./migrations/0001_entries.js";
5
+ import { leaderboardExampleSeed } from "./seeds/example.js";
6
+ import { PACKAGE_VERSION } from "./version.generated.js";
7
+ import { defineCapability } from "@pithy-sh/core/src/capability/capability";
8
+ //#region src/capability.ts
9
+ /**
10
+ * Where leaderboard's migrations sort in the app database. Unique per database; the registry composes
11
+ * keys like `0400_leaderboard_0001_entries`. Sits after media (300) and audit (250).
12
+ */
13
+ const LEADERBOARD_MIGRATION_ORDER = 400;
14
+ /**
15
+ * The leaderboard capability: submit a score, read the standings, read your own rank — across windows.
16
+ *
17
+ * Fully optional. Config, migrations, routes, and bindings arrive only on `pithy add leaderboard`, and
18
+ * `pithy remove leaderboard` is the clean inverse.
19
+ *
20
+ * One store, always: D1. There is no engine flag, because Durable Objects do not fix what a leaderboard
21
+ * actually strains against. A DO bills rows exactly as D1 does, so a rank scan inside one costs the same;
22
+ * it adds request and duration billing D1 does not have; and each object is single-threaded with a soft
23
+ * 1,000 req/s ceiling and the same 10 GB cap, so it does not relieve hot-board serialization either.
24
+ * Scale is a cadence dial (`rank: { materialize }`), which is pure D1. A DO earns its place only when
25
+ * `live: true` ships — as a WebSocket push layer *over* D1, which is a latency play, not a store.
26
+ *
27
+ * `dependsOn` is deliberately empty. Auth is not a peer capability but a seam: the routes read
28
+ * `c.var.auth` through core's `AuthContext`, so without `@pithy-sh/auth` installed every route is denied
29
+ * rather than open. That is the right failure and it needs no dependency edge.
30
+ */
31
+ function leaderboard(options = { boards: [] }) {
32
+ const { basePath, ...configInput } = options;
33
+ const resolved = LeaderboardConfig.parse(configInput);
34
+ const migrations = { "0001_entries": leaderboard_0001_entries };
35
+ const capability = defineCapability({
36
+ name: "leaderboard",
37
+ version: PACKAGE_VERSION,
38
+ requiredBindings: [{
39
+ type: "d1",
40
+ name: "DB"
41
+ }],
42
+ config: LeaderboardConfig,
43
+ databases: { app: {
44
+ binding: "DB",
45
+ tables: leaderboardTables(),
46
+ migrationOrder: 400,
47
+ migrations
48
+ } },
49
+ routes: registerLeaderboardRoutes({
50
+ config: resolved,
51
+ basePath
52
+ }),
53
+ seeds: [leaderboardExampleSeed]
54
+ });
55
+ return Object.assign(capability, { leaderboardConfig: resolved });
56
+ }
57
+ function isLeaderboardCapability(capability) {
58
+ return capability.name === "leaderboard" && "leaderboardConfig" in capability;
59
+ }
60
+ /**
61
+ * Whether this configuration needs the rank worker deployed.
62
+ *
63
+ * Two things need it: materialized rank (the refresh), and configured retention (the prune). Retention
64
+ * now defaults to keep-all, so a windowed board only needs the worker if it actually sets `retain` or
65
+ * `retainDays` — a board that keeps everything has nothing to prune. A live, keep-all board set needs no
66
+ * worker at all.
67
+ */
68
+ function needsRankWorker(config) {
69
+ if (materializeSchedule(config) !== void 0) return true;
70
+ return config.boards.some((board) => board.retain !== void 0 || board.retainDays !== void 0);
71
+ }
72
+ //#endregion
73
+ export { LEADERBOARD_MIGRATION_ORDER, isLeaderboardCapability, leaderboard, needsRankWorker };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The board-key pattern, in the one module that has no reason to import anything (#430).
3
+ *
4
+ * Both `config/config.ts` and `http/schemas.ts` constrain a board key with this regex, and the schema
5
+ * used to take it from the config module. That module validates a board's cron window through
6
+ * `window/schedule.ts`, which imports `croner` — so a request schema, which a management client compiles
7
+ * in a browser to build a call, was dragging a cron parser in to spell one regex. `croner` runs in a
8
+ * browser perfectly well, and that is exactly why widening the allowlist would have been the wrong fix:
9
+ * the rule is what a browser build may reach, not what it can survive.
10
+ *
11
+ * A board key is a URL path segment (`/leaderboard/<key>/top`), so it is kebab-case and lowercase.
12
+ */
13
+ export declare const BOARD_KEY_PATTERN: RegExp;
14
+ //# sourceMappingURL=boardKey.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"boardKey.d.ts","sourceRoot":"","sources":["../../src/config/boardKey.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB,QAAyB,CAAC"}
@@ -0,0 +1,16 @@
1
+ //#region src/config/boardKey.ts
2
+ /**
3
+ * The board-key pattern, in the one module that has no reason to import anything (#430).
4
+ *
5
+ * Both `config/config.ts` and `http/schemas.ts` constrain a board key with this regex, and the schema
6
+ * used to take it from the config module. That module validates a board's cron window through
7
+ * `window/schedule.ts`, which imports `croner` — so a request schema, which a management client compiles
8
+ * in a browser to build a call, was dragging a cron parser in to spell one regex. `croner` runs in a
9
+ * browser perfectly well, and that is exactly why widening the allowlist would have been the wrong fix:
10
+ * the rule is what a browser build may reach, not what it can survive.
11
+ *
12
+ * A board key is a URL path segment (`/leaderboard/<key>/top`), so it is kebab-case and lowercase.
13
+ */
14
+ const BOARD_KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
15
+ //#endregion
16
+ export { BOARD_KEY_PATTERN };
@@ -0,0 +1,96 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * The leaderboard capability's config — the thin, user-owned surface in `pithy.config.ts`. Every field
4
+ * is `.describe()`d: the descriptions feed the self-documenting CLI, so a non-expert can pick the right
5
+ * board shape and the right `rank` mode from the CLI's questions alone (CLAUDE.md §Config).
6
+ *
7
+ * A board definition is the unit of config. There is no boards table to administer and no dashboard to
8
+ * click through — the board set is code, reviewed and deployed like the rest of the app. What the
9
+ * database records is only what config cannot: the entries, and a fingerprint of each board's immutable
10
+ * fields so a later edit cannot silently reinterpret scores already stored (see `data/boardRecord`).
11
+ */
12
+ export declare const ScoreDirection: z.ZodEnum<{
13
+ asc: "asc";
14
+ desc: "desc";
15
+ }>;
16
+ export type ScoreDirection = z.infer<typeof ScoreDirection>;
17
+ export declare const ScoreAggregation: z.ZodEnum<{
18
+ best: "best";
19
+ latest: "latest";
20
+ sum: "sum";
21
+ }>;
22
+ export type ScoreAggregation = z.infer<typeof ScoreAggregation>;
23
+ export declare const LeaderboardStore: z.ZodLiteral<"d1">;
24
+ export type LeaderboardStore = z.infer<typeof LeaderboardStore>;
25
+ export declare const LeaderboardTier: z.ZodObject<{
26
+ key: z.ZodString;
27
+ from: z.ZodNumber;
28
+ }, z.core.$strip>;
29
+ export type LeaderboardTier = z.infer<typeof LeaderboardTier>;
30
+ export declare const LeaderboardBoard: z.ZodObject<{
31
+ key: z.ZodString;
32
+ store: z.ZodDefault<z.ZodLiteral<"d1">>;
33
+ direction: z.ZodEnum<{
34
+ asc: "asc";
35
+ desc: "desc";
36
+ }>;
37
+ aggregation: z.ZodDefault<z.ZodEnum<{
38
+ best: "best";
39
+ latest: "latest";
40
+ sum: "sum";
41
+ }>>;
42
+ window: z.ZodOptional<z.ZodString>;
43
+ retain: z.ZodOptional<z.ZodNumber>;
44
+ retainDays: z.ZodOptional<z.ZodNumber>;
45
+ min: z.ZodOptional<z.ZodNumber>;
46
+ max: z.ZodOptional<z.ZodNumber>;
47
+ tiers: z.ZodOptional<z.ZodArray<z.ZodObject<{
48
+ key: z.ZodString;
49
+ from: z.ZodNumber;
50
+ }, z.core.$strip>>>;
51
+ trackActivity: z.ZodDefault<z.ZodBoolean>;
52
+ }, z.core.$strip>;
53
+ export type LeaderboardBoard = z.output<typeof LeaderboardBoard>;
54
+ export declare const LeaderboardRank: z.ZodUnion<readonly [z.ZodLiteral<"live">, z.ZodObject<{
55
+ materialize: z.ZodString;
56
+ }, z.core.$strip>]>;
57
+ export type LeaderboardRank = z.output<typeof LeaderboardRank>;
58
+ export declare const LeaderboardConfig: z.ZodObject<{
59
+ boards: z.ZodArray<z.ZodObject<{
60
+ key: z.ZodString;
61
+ store: z.ZodDefault<z.ZodLiteral<"d1">>;
62
+ direction: z.ZodEnum<{
63
+ asc: "asc";
64
+ desc: "desc";
65
+ }>;
66
+ aggregation: z.ZodDefault<z.ZodEnum<{
67
+ best: "best";
68
+ latest: "latest";
69
+ sum: "sum";
70
+ }>>;
71
+ window: z.ZodOptional<z.ZodString>;
72
+ retain: z.ZodOptional<z.ZodNumber>;
73
+ retainDays: z.ZodOptional<z.ZodNumber>;
74
+ min: z.ZodOptional<z.ZodNumber>;
75
+ max: z.ZodOptional<z.ZodNumber>;
76
+ tiers: z.ZodOptional<z.ZodArray<z.ZodObject<{
77
+ key: z.ZodString;
78
+ from: z.ZodNumber;
79
+ }, z.core.$strip>>>;
80
+ trackActivity: z.ZodDefault<z.ZodBoolean>;
81
+ }, z.core.$strip>>;
82
+ rank: z.ZodPrefault<z.ZodUnion<readonly [z.ZodLiteral<"live">, z.ZodObject<{
83
+ materialize: z.ZodString;
84
+ }, z.core.$strip>]>>;
85
+ serverAuthoritative: z.ZodDefault<z.ZodBoolean>;
86
+ submitScope: z.ZodDefault<z.ZodString>;
87
+ adminScope: z.ZodDefault<z.ZodString>;
88
+ visibleByDefault: z.ZodDefault<z.ZodBoolean>;
89
+ }, z.core.$strip>;
90
+ export type LeaderboardConfig = z.output<typeof LeaderboardConfig>;
91
+ export type LeaderboardConfigInput = z.input<typeof LeaderboardConfig>;
92
+ /** The board with this key, or undefined. Board keys come from config, so an unknown key is a 404. */
93
+ export declare function resolveBoard(config: LeaderboardConfig, key: string): LeaderboardBoard | undefined;
94
+ /** The rank refresh CRON, or undefined when rank is live and no refresh worker is needed. */
95
+ export declare function materializeSchedule(config: LeaderboardConfig): string | undefined;
96
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config/config.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB;;;;;;;;;GASG;AAEH,eAAO,MAAM,cAAc;;;EAIxB,CAAC;AACJ,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D,eAAO,MAAM,gBAAgB;;;;EAI1B,CAAC;AACJ,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE,eAAO,MAAM,gBAAgB,oBAI1B,CAAC;AACJ,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEhE,eAAO,MAAM,eAAe;;;iBAYmD,CAAC;AAChF,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAE9D,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;iBAkHzB,CAAC;AACL,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAEjE,eAAO,MAAM,eAAe;;mBAiBzB,CAAC;AACJ,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,eAAe,CAAC,CAAC;AAE/D,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAyD1B,CAAC;AACL,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,iBAAiB,CAAC,CAAC;AACnE,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAEvE,sGAAsG;AACtG,wBAAgB,YAAY,CAAC,MAAM,EAAE,iBAAiB,EAAE,GAAG,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAEjG;AAED,6FAA6F;AAC7F,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,iBAAiB,GAAG,MAAM,GAAG,SAAS,CAEjF"}
@@ -0,0 +1,121 @@
1
+ import { assertValidSchedule } from "../window/schedule.js";
2
+ import { BOARD_KEY_PATTERN } from "./boardKey.js";
3
+ import { z } from "zod";
4
+ //#region src/config/config.ts
5
+ /**
6
+ * The leaderboard capability's config — the thin, user-owned surface in `pithy.config.ts`. Every field
7
+ * is `.describe()`d: the descriptions feed the self-documenting CLI, so a non-expert can pick the right
8
+ * board shape and the right `rank` mode from the CLI's questions alone (CLAUDE.md §Config).
9
+ *
10
+ * A board definition is the unit of config. There is no boards table to administer and no dashboard to
11
+ * click through — the board set is code, reviewed and deployed like the rest of the app. What the
12
+ * database records is only what config cannot: the entries, and a fingerprint of each board's immutable
13
+ * fields so a later edit cannot silently reinterpret scores already stored (see `data/boardRecord`).
14
+ */
15
+ const ScoreDirection = z.enum(["asc", "desc"]).describe("Which way a score sorts: `desc` = highest wins (points, distance), `asc` = lowest wins (lap time, strokes). Immutable after the board records its first entry — every vendor surveyed treats it that way, and flipping it would silently reinterpret every stored score.");
16
+ const ScoreAggregation = z.enum([
17
+ "best",
18
+ "latest",
19
+ "sum"
20
+ ]).describe("How repeat submissions combine into one entry: `best` keeps the best score in the board's direction, `latest` overwrites, `sum` accumulates. Orthogonal to direction — only `best` reads it.");
21
+ const LeaderboardStore = z.literal("d1").describe("Which backing store ranks this board. Today the only value is `d1`: exact ranking on your own D1, the store this whole capability is built on. It is a per-board discriminant on purpose — the issue settled `Store: D1, always` against a Durable-Object *engine* flag (a DO fixes neither cost nor throughput), but a future column-oriented store is a different axis: an Analytics-Engine-shaped board would be far cheaper at scale yet *approximate* (it samples on read and write), so it can only ever back a separate, opt-in `approximate` board type — never replace exact `d1` ranking. This field is the seam that would let such a board live beside a `d1` one, chosen per board. It is immutable once a board records entries: there is no safe automatic migration of scores between stores.");
22
+ const LeaderboardTier = z.object({
23
+ key: z.string().min(1).describe("The tier's name as returned to clients — `gold`, `diamond`, whatever your game calls it."),
24
+ from: z.number().describe("The score at which this tier begins, inclusive. Read in the board's direction: on a `desc` board a score at or above `from` qualifies; on an `asc` board a score at or below it does.")
25
+ }).describe("One tier threshold — a named band of scores, classified on read.");
26
+ const LeaderboardBoard = z.object({
27
+ key: z.string().regex(BOARD_KEY_PATTERN, "A board key is lowercase, digits, and dashes — it is a URL path segment.").describe("The board's stable id, unique across the app. It is a URL path segment and an entry key."),
28
+ store: LeaderboardStore.default("d1").describe("Which backing store ranks this board. Only `d1` today."),
29
+ direction: ScoreDirection.describe("Which way this board's scores sort. Required — there is no safe default."),
30
+ aggregation: ScoreAggregation.default("best").describe("How this board folds repeat submissions from the same player in the same window."),
31
+ window: z.string().optional().describe("A UTC CRON expression marking where each window opens; omit for one all-time board. `0 0 * * *` is daily, `0 0 * * 1` weekly, `0 0 1 * *` a calendar month, `0 0 1 1 *` a calendar year. CRON rather than a fixed enum is what makes calendar months and years expressible at all — Apple caps recurrence at 30 fixed days and Google offers no monthly."),
32
+ retain: z.number().int().min(0).optional().describe("How many closed windows to keep before the retention sweep deletes the rest — a *product* limit (\"users can browse the last 12 weeks\"). Omit to keep every window forever, which is the default: storage is never the cost driver here (see docs/costs.md), so nothing is deleted unless you ask. Ignored on an all-time board, which never closes a window. Set this OR `retainDays`, not both."),
33
+ retainDays: z.number().int().min(1).optional().describe("Delete windows whose data is older than this many days — a *compliance* limit (\"nothing older than 90 days\"), independent of window cadence. Omit to keep everything (the default). Ignored on an all-time board, whose single window has no age. Set this OR `retain`, not both."),
34
+ min: z.number().optional().describe("The lowest score this board will accept. Server-side bounds are the anti-cheat baseline: a client that posts outside them is rejected, not ranked."),
35
+ max: z.number().optional().describe("The highest score this board will accept."),
36
+ tiers: z.array(LeaderboardTier).optional().describe("Named score bands, listed worst to best. Classified on read from the score already stored — a tier costs nothing on write and no second board."),
37
+ trackActivity: z.boolean().default(false).describe("Whether every submission is written, or only ones that change the ranked score. Default `false`: on a `best` board a submission that fails to beat the stored score writes *nothing* — zero rows billed — because the guard skips it. That is the single biggest cost lever this capability has, since submission writes dominate the bill and most submissions do not improve a player's best (see docs/costs.md). The cost is that `submittedAt` then advances only on an improving submission, so it stops being a record of *activity* and becomes a record of *progress*. Set `true` to write on every submission and keep `submittedAt` a true last-seen timestamp — at full write cost. Ignored on `sum` and `latest` boards, where every submission changes the score and therefore always writes.")
38
+ }).describe("One board definition — the unit of leaderboard config.").check((ctx) => {
39
+ const { min, max, window, tiers, direction, retain, retainDays } = ctx.value;
40
+ if (min !== void 0 && max !== void 0 && min > max) ctx.issues.push({
41
+ code: "custom",
42
+ input: ctx.value,
43
+ path: ["min"],
44
+ message: `Board "${ctx.value.key}" sets min ${min} above max ${max}, so every score would be rejected.`
45
+ });
46
+ if (retain !== void 0 && retainDays !== void 0) ctx.issues.push({
47
+ code: "custom",
48
+ input: ctx.value,
49
+ path: ["retainDays"],
50
+ message: `Board "${ctx.value.key}" sets both retain and retainDays. Set one: retain for a window-count limit, retainDays for an age limit.`
51
+ });
52
+ if ((retain !== void 0 || retainDays !== void 0) && window === void 0) ctx.issues.push({
53
+ code: "custom",
54
+ input: ctx.value,
55
+ path: [retainDays !== void 0 ? "retainDays" : "retain"],
56
+ message: `Board "${ctx.value.key}" is all-time (no window), so retention does not apply. Remove retain/retainDays or give the board a window.`
57
+ });
58
+ if (window !== void 0) try {
59
+ assertValidSchedule(window);
60
+ } catch (error) {
61
+ ctx.issues.push({
62
+ code: "custom",
63
+ input: ctx.value,
64
+ path: ["window"],
65
+ message: `Board "${ctx.value.key}" has an invalid window schedule: ${error.message}`
66
+ });
67
+ }
68
+ if (tiers) for (let i = 1; i < tiers.length; i++) {
69
+ const previous = tiers[i - 1];
70
+ const current = tiers[i];
71
+ if (!(direction === "desc" ? current.from > previous.from : current.from < previous.from)) ctx.issues.push({
72
+ code: "custom",
73
+ input: ctx.value,
74
+ path: [
75
+ "tiers",
76
+ i,
77
+ "from"
78
+ ],
79
+ message: `Board "${ctx.value.key}" lists tier "${current.key}" after "${previous.key}", but its threshold does not improve on a ${direction} board. List tiers worst to best.`
80
+ });
81
+ }
82
+ });
83
+ const LeaderboardRank = z.union([z.literal("live").describe("Rank is counted at read time. Always correct, no moving parts, and $0 under ~10k players."), z.object({ materialize: z.string().describe("A UTC CRON expression for the rank refresh pass. `0 * * * *` is hourly. Faster means fresher ranks and a bigger bill; the cadence is a dial, not a switch.") }).describe("Rank is stored on the entry and refreshed on a schedule, making my-rank a single point read.")]).describe("How rank is computed. `live` counts better entries per request — correct and free for small boards, and quadratic as they grow. `{ materialize }` trades rank staleness for cost and is the documented path past ~100k players; a player's own score stays live either way. See docs/costs.md before choosing.");
84
+ const LeaderboardConfig = z.object({
85
+ boards: z.array(LeaderboardBoard).min(1, "A leaderboard capability with no boards does nothing — configure at least one.").describe("Every board this app ranks. The board set is config, not database rows."),
86
+ rank: LeaderboardRank.prefault("live").describe("How rank is computed across every board."),
87
+ serverAuthoritative: z.boolean().default(true).describe("Require the submit scope to post a score. On by default, inverting the vendor norm — every platform that offers server-authoritative writes ships it off. Leave it on and submit from your trusted server; turn it off only if you accept that a player's device can post any score it likes."),
88
+ submitScope: z.string().min(1).default("leaderboard:submit").describe("The AuthContext scope a session must carry to submit a score while `serverAuthoritative` is on. Mint it for your server's own token, never for a player's."),
89
+ adminScope: z.string().min(1).default("leaderboard:admin").describe("The AuthContext scope required to hide or remove another player's entry."),
90
+ visibleByDefault: z.boolean().default(true).describe("Whether a new entry appears on the board before the player says otherwise. Set false to make the board opt-in, so a player only shows up once they consent.")
91
+ }).describe("Configuration for the leaderboard capability — the board set, how rank is computed, and who may write.").check((ctx) => {
92
+ const keys = ctx.value.boards.map((b) => b.key);
93
+ const duplicates = [...new Set(keys.filter((key, i) => keys.indexOf(key) !== i))];
94
+ if (duplicates.length > 0) ctx.issues.push({
95
+ code: "custom",
96
+ input: ctx.value,
97
+ path: ["boards"],
98
+ message: `Duplicate board keys: ${duplicates.join(", ")}. Two boards sharing a key would merge their entries.`
99
+ });
100
+ const rank = ctx.value.rank;
101
+ if (typeof rank === "object") try {
102
+ assertValidSchedule(rank.materialize);
103
+ } catch (error) {
104
+ ctx.issues.push({
105
+ code: "custom",
106
+ input: ctx.value,
107
+ path: ["rank", "materialize"],
108
+ message: `Invalid materialize schedule: ${error.message}`
109
+ });
110
+ }
111
+ });
112
+ /** The board with this key, or undefined. Board keys come from config, so an unknown key is a 404. */
113
+ function resolveBoard(config, key) {
114
+ return config.boards.find((board) => board.key === key);
115
+ }
116
+ /** The rank refresh CRON, or undefined when rank is live and no refresh worker is needed. */
117
+ function materializeSchedule(config) {
118
+ return typeof config.rank === "object" ? config.rank.materialize : void 0;
119
+ }
120
+ //#endregion
121
+ export { LeaderboardBoard, LeaderboardConfig, LeaderboardRank, LeaderboardStore, LeaderboardTier, ScoreAggregation, ScoreDirection, materializeSchedule, resolveBoard };
@@ -0,0 +1,36 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * The recorded definition of a board that has started taking entries — the row in
4
+ * `pithy_leaderboard_boards`.
5
+ *
6
+ * Boards are config, not database rows, so this table holds no board *settings*. It holds only the three
7
+ * fields that may never change once a score exists, so that changing one in `pithy.config.ts` fails loudly
8
+ * instead of silently reinterpreting stored data:
9
+ *
10
+ * - `direction` — flipping it turns every leader into a laggard.
11
+ * - `aggregation` — switching `best` to `sum` makes existing rows mean something they never meant.
12
+ * - `window` — a new schedule keys new scores into windows that do not line up with the stored ones.
13
+ *
14
+ * Every vendor surveyed treats these as create-time and immutable. Pithy cannot enforce that in the type
15
+ * system because the board set is config the adopter edits freely, so it enforces it here, on first write
16
+ * of each window, against what was actually recorded.
17
+ */
18
+ export declare const LeaderboardBoardRecord: z.ZodObject<{
19
+ id: z.ZodNumber;
20
+ boardKey: z.ZodString;
21
+ store: z.ZodLiteral<"d1">;
22
+ direction: z.ZodEnum<{
23
+ asc: "asc";
24
+ desc: "desc";
25
+ }>;
26
+ aggregation: z.ZodEnum<{
27
+ best: "best";
28
+ latest: "latest";
29
+ sum: "sum";
30
+ }>;
31
+ window: z.ZodOptional<z.ZodNullable<z.ZodString>>;
32
+ createdAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
33
+ }, z.core.$strip>;
34
+ export type LeaderboardBoardRecord = z.output<typeof LeaderboardBoardRecord>;
35
+ export type LeaderboardBoardRecordRow = z.input<typeof LeaderboardBoardRecord>;
36
+ //# sourceMappingURL=boardRecord.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"boardRecord.d.ts","sourceRoot":"","sources":["../../src/data/boardRecord.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;;iBAaiE,CAAC;AACrG,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAC7E,MAAM,MAAM,yBAAyB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC"}
@@ -0,0 +1,31 @@
1
+ import { LeaderboardStore, ScoreAggregation, ScoreDirection } from "../config/config.js";
2
+ import { z } from "zod";
3
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
4
+ //#region src/data/boardRecord.ts
5
+ /**
6
+ * The recorded definition of a board that has started taking entries — the row in
7
+ * `pithy_leaderboard_boards`.
8
+ *
9
+ * Boards are config, not database rows, so this table holds no board *settings*. It holds only the three
10
+ * fields that may never change once a score exists, so that changing one in `pithy.config.ts` fails loudly
11
+ * instead of silently reinterpreting stored data:
12
+ *
13
+ * - `direction` — flipping it turns every leader into a laggard.
14
+ * - `aggregation` — switching `best` to `sum` makes existing rows mean something they never meant.
15
+ * - `window` — a new schedule keys new scores into windows that do not line up with the stored ones.
16
+ *
17
+ * Every vendor surveyed treats these as create-time and immutable. Pithy cannot enforce that in the type
18
+ * system because the board set is config the adopter edits freely, so it enforces it here, on first write
19
+ * of each window, against what was actually recorded.
20
+ */
21
+ const LeaderboardBoardRecord = z.object({
22
+ id: z.number().int().describe("Autoincrement primary key."),
23
+ boardKey: z.string().describe("The board key this definition belongs to. Unique — one record per board."),
24
+ store: LeaderboardStore.describe("The backing store recorded when this board took its first entry."),
25
+ direction: ScoreDirection.describe("The direction recorded when this board took its first entry."),
26
+ aggregation: ScoreAggregation.describe("The aggregation recorded when this board took its first entry."),
27
+ window: z.string().nullish().describe("The window CRON recorded when this board took its first entry; null on an all-time board."),
28
+ createdAt: SQLiteDate.describe("When this board first recorded an entry.")
29
+ }).describe("The immutable fields of a board that has started recording entries — the drift guard.");
30
+ //#endregion
31
+ export { LeaderboardBoardRecord };
@@ -0,0 +1,29 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * One player's state on one board in one window — the row in `pithy_leaderboard_entries`.
4
+ *
5
+ * `z.output` is the app shape (Dates, booleans); `z.input` is the SQLite row (ms-epoch, 0|1). All
6
+ * JS↔SQLite conversion runs through the core codecs — no raw `0/1`, epoch, or `new Date()` in query code.
7
+ *
8
+ * The entry is keyed `(boardId, windowKey, userId)`: each window carries its own aggregation state rather
9
+ * than being a query-time filter over an append log. That is what makes `best` and `sum` expressible per
10
+ * window at all, and what keeps a read O(one row) instead of O(a player's whole history).
11
+ *
12
+ * `id` is an autoincrement integer, not a UUID: an entry id is never handed to a client or embedded in a
13
+ * URL — every route addresses an entry by its natural key — so there is nothing to enumerate.
14
+ */
15
+ export declare const LeaderboardEntry: z.ZodObject<{
16
+ id: z.ZodNumber;
17
+ boardId: z.ZodString;
18
+ windowKey: z.ZodString;
19
+ userId: z.ZodString;
20
+ score: z.ZodNumber;
21
+ achievedAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
22
+ submittedAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
23
+ visible: z.ZodCodec<z.ZodUnion<readonly [z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodBoolean, z.ZodString]>, z.ZodBoolean>;
24
+ hidden: z.ZodCodec<z.ZodUnion<readonly [z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodBoolean, z.ZodString]>, z.ZodBoolean>;
25
+ rank: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
26
+ }, z.core.$strip>;
27
+ export type LeaderboardEntry = z.output<typeof LeaderboardEntry>;
28
+ export type LeaderboardEntryRow = z.input<typeof LeaderboardEntry>;
29
+ //# sourceMappingURL=entry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"entry.d.ts","sourceRoot":"","sources":["../../src/data/entry.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB;;;;;;;;;;;iBAqCyE,CAAC;AACvG,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,gBAAgB,CAAC,CAAC;AACjE,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC"}
@@ -0,0 +1,30 @@
1
+ import { z } from "zod";
2
+ import { SQLiteBoolean, SQLiteDate } from "@pithy-sh/core/src/data/codecs";
3
+ //#region src/data/entry.ts
4
+ /**
5
+ * One player's state on one board in one window — the row in `pithy_leaderboard_entries`.
6
+ *
7
+ * `z.output` is the app shape (Dates, booleans); `z.input` is the SQLite row (ms-epoch, 0|1). All
8
+ * JS↔SQLite conversion runs through the core codecs — no raw `0/1`, epoch, or `new Date()` in query code.
9
+ *
10
+ * The entry is keyed `(boardId, windowKey, userId)`: each window carries its own aggregation state rather
11
+ * than being a query-time filter over an append log. That is what makes `best` and `sum` expressible per
12
+ * window at all, and what keeps a read O(one row) instead of O(a player's whole history).
13
+ *
14
+ * `id` is an autoincrement integer, not a UUID: an entry id is never handed to a client or embedded in a
15
+ * URL — every route addresses an entry by its natural key — so there is nothing to enumerate.
16
+ */
17
+ const LeaderboardEntry = z.object({
18
+ id: z.number().int().describe("Autoincrement primary key. Internal only; entries are addressed by natural key."),
19
+ boardId: z.string().describe("The board key this entry ranks on, from `boards` in pithy.config.ts."),
20
+ windowKey: z.string().describe("The window this entry belongs to: the ISO instant the board's CRON last fired at or before the score, or `all` on an all-time board."),
21
+ userId: z.string().describe("The authenticated player, from the core AuthContext seam. Never read from the request body — that would let any caller score as anyone."),
22
+ score: z.number().describe("The player's current score for this window, already folded by the board's aggregation."),
23
+ achievedAt: SQLiteDate.describe("When the current score was first reached. The primary tiebreak: on equal scores the player who got there first ranks higher."),
24
+ submittedAt: SQLiteDate.describe("When this entry was last written. Distinct from achievedAt — a submission that fails to improve a `best` board still touches this."),
25
+ visible: SQLiteBoolean.describe("Whether the player consents to appear. Gates every read, including friends and segment queries."),
26
+ hidden: SQLiteBoolean.describe("Whether an admin has hidden this entry. Distinct from `visible` so a moderator action cannot be undone by the player toggling their own consent."),
27
+ rank: z.number().int().nullish().describe("The materialized rank, refreshed by the rank worker. Null while rank is live, and null on a fresh entry until the next refresh pass reaches it.")
28
+ }).describe("One player's entry on one board in one window — the row in `pithy_leaderboard_entries`.");
29
+ //#endregion
30
+ export { LeaderboardEntry };
@@ -0,0 +1,18 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * A single-row advisory lock in `pithy_leaderboard_locks` — the row in the table, one per lock name.
4
+ *
5
+ * It serializes the rank-refresh Workflow: at most one instance holds the `rank-refresh` lock at a time,
6
+ * so two overlapping cron fires can never interleave their chunked rank writes into an incoherent set.
7
+ *
8
+ * `z.output` is the app shape (a Date); `z.input` is the SQLite row (ms-epoch). Conversion runs through
9
+ * the core codec, per the round-trip rule.
10
+ */
11
+ export declare const LeaderboardLock: z.ZodObject<{
12
+ name: z.ZodString;
13
+ holder: z.ZodString;
14
+ acquiredAt: z.ZodCodec<z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodDate]>, z.ZodDate>;
15
+ }, z.core.$strip>;
16
+ export type LeaderboardLock = z.output<typeof LeaderboardLock>;
17
+ export type LeaderboardLockRow = z.input<typeof LeaderboardLock>;
18
+ //# sourceMappingURL=lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lock.d.ts","sourceRoot":"","sources":["../../src/data/lock.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe;;;;iBAQkC,CAAC;AAC/D,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,eAAe,CAAC,CAAC;AAC/D,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC"}