@pithy-sh/rating 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,332 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
5
+ import { z } from "zod";
6
+ import type { RatingAlgorithm } from "../algorithm";
7
+
8
+ /**
9
+ * TrueSkill — Microsoft's Bayesian skill-rating system, the built-in that rates *any* format: 1v1,
10
+ * N-player free-for-all, and teams. Each player is a Gaussian belief over their true skill `N(μ, σ²)`;
11
+ * a game shifts every belief toward the result, growing confident (σ shrinks) as evidence accrues. The
12
+ * exposed, matchmaking-comparable number is the **conservative** skill `μ − 3σ`: a rating the player is
13
+ * ~99.7% likely to exceed, so a newcomer's wide σ keeps them provisional until they've played.
14
+ *
15
+ * ## What this implementation is
16
+ * A faithful, self-contained port of the standard two-team TrueSkill update (Herbrich et al. 2007), with
17
+ * one deliberate approximation for 3+ teams. It carries its own truncated-Gaussian corrections (`v`/`w`)
18
+ * and its own normal `pdf`/`cdf`/`ppf` — no external math dependency, pure and deterministic.
19
+ *
20
+ * ### The two-team update (exact for 1v1, N-player-one-vs-one, and 2-team formats)
21
+ * Group players into teams. A team's mean is the **sum** of its members' μ; its variance is the **sum**
22
+ * of members' σ² (after adding the dynamics term τ² to each player first, so uncertainty never collapses
23
+ * to zero between games). For a winner team `w` and loser team `l`:
24
+ * ```
25
+ * c² = varW + varL + 2β² t = (μW − μL) / c ε = drawMargin / c
26
+ * v, w = truncated-Gaussian corrections at (t, ε) // win vs draw branch
27
+ * each player i: μ_i += ±(σ_i²/c)·v σ_i² *= (1 − (σ_i²/c²)·w)
28
+ * ```
29
+ * where `±` is `+` for the winning team and `−` for the losing team, and `σ_i²` is that player's
30
+ * post-dynamics variance. The draw margin is `ppf((drawProbability+1)/2) · √n · β` with **n = 2** per
31
+ * pairwise team comparison (the two "sides" of one comparison), standardized by dividing by `c`.
32
+ *
33
+ * ### 3+ teams — the adjacent-pair sequential approximation (DOCUMENTED APPROXIMATION)
34
+ * A fully general N-team factor graph is not run. Instead teams are sorted by finishing place and each
35
+ * **adjacent** pair (1st-vs-2nd, 2nd-vs-3rd, …) is updated as an independent two-team comparison off the
36
+ * players' *original* (post-dynamics) states; per player the μ-deltas are **summed** and the σ² factors
37
+ * are **multiplied**. This is the well-known sequential TrueSkill approximation: correct for 1v1 and
38
+ * two-team games, and a close, order-consistent estimate for free-for-alls (a middle finisher who beats
39
+ * the player below and loses to the one above nets out near their prior, exactly as expected).
40
+ */
41
+
42
+ // ---------------------------------------------------------------------------------------------------
43
+ // Standard-normal helpers — erf (Abramowitz & Stegun 7.1.26, |error| ≤ 1.5e-7), its cdf, pdf, and the
44
+ // inverse-cdf ppf (Acklam's rational approximation, |rel. error| ≈ 1.15e-9). Exported so their accuracy
45
+ // is asserted directly in the test. Pure functions of their argument.
46
+ // ---------------------------------------------------------------------------------------------------
47
+
48
+ const INV_SQRT_2PI = 0.3989422804014327; // 1 / √(2π)
49
+
50
+ /** The Gauss error function, `erf(x)`, via Abramowitz & Stegun 7.1.26 (max abs error ~1.5e-7). */
51
+ export function erf(x: number): number {
52
+ const sign = x < 0 ? -1 : 1;
53
+ const ax = Math.abs(x);
54
+ const t = 1 / (1 + 0.3275911 * ax);
55
+ const poly = ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t;
56
+ return sign * (1 - poly * Math.exp(-ax * ax));
57
+ }
58
+
59
+ /** Standard-normal probability density `φ(x)`. */
60
+ export function normPdf(x: number): number {
61
+ return INV_SQRT_2PI * Math.exp(-0.5 * x * x);
62
+ }
63
+
64
+ /** Standard-normal cumulative distribution `Φ(x)`. */
65
+ export function normCdf(x: number): number {
66
+ return 0.5 * (1 + erf(x / Math.SQRT2));
67
+ }
68
+
69
+ /** Standard-normal inverse cdf (quantile) `Φ⁻¹(p)`, Acklam's approximation. `p∈(0,1)`. */
70
+ export function normPpf(p: number): number {
71
+ if (p <= 0) return Number.NEGATIVE_INFINITY;
72
+ if (p >= 1) return Number.POSITIVE_INFINITY;
73
+ // Central (a/b) and tail (c/d) rational-approximation coefficients.
74
+ const a0 = -3.969683028665376e1;
75
+ const a1 = 2.209460984245205e2;
76
+ const a2 = -2.759285104469687e2;
77
+ const a3 = 1.38357751867269e2;
78
+ const a4 = -3.066479806614716e1;
79
+ const a5 = 2.506628277459239;
80
+ const b0 = -5.447609879822406e1;
81
+ const b1 = 1.615858368580409e2;
82
+ const b2 = -1.556989798598866e2;
83
+ const b3 = 6.680131188771972e1;
84
+ const b4 = -1.328068155288572e1;
85
+ const c0 = -7.784894002430293e-3;
86
+ const c1 = -3.223964580411365e-1;
87
+ const c2 = -2.400758277161838;
88
+ const c3 = -2.549732539343734;
89
+ const c4 = 4.374664141464968;
90
+ const c5 = 2.938163982698783;
91
+ const d0 = 7.784695709041462e-3;
92
+ const d1 = 3.224671290700398e-1;
93
+ const d2 = 2.445134137142996;
94
+ const d3 = 3.754408661907416;
95
+ const pLow = 0.02425;
96
+ const pHigh = 1 - pLow;
97
+ if (p < pLow) {
98
+ const q = Math.sqrt(-2 * Math.log(p));
99
+ return (((((c0 * q + c1) * q + c2) * q + c3) * q + c4) * q + c5) / ((((d0 * q + d1) * q + d2) * q + d3) * q + 1);
100
+ }
101
+ if (p <= pHigh) {
102
+ const q = p - 0.5;
103
+ const r = q * q;
104
+ return (
105
+ ((((((a0 * r + a1) * r + a2) * r + a3) * r + a4) * r + a5) * q) /
106
+ (((((b0 * r + b1) * r + b2) * r + b3) * r + b4) * r + 1)
107
+ );
108
+ }
109
+ const q = Math.sqrt(-2 * Math.log(1 - p));
110
+ return -((((((c0 * q + c1) * q + c2) * q + c3) * q + c4) * q + c5) / ((((d0 * q + d1) * q + d2) * q + d3) * q + 1));
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------------------------------
114
+ // Truncated-Gaussian corrections. Arguments are already standardized (divided by c): `t` is the scaled
115
+ // team-mean difference, `eps` the scaled draw margin. `v` is the additive mean multiplier, `w∈(0,1)` the
116
+ // multiplicative variance shrink. The draw variants follow the canonical signed forms (Herbrich et al.).
117
+ // ---------------------------------------------------------------------------------------------------
118
+
119
+ /** Mean multiplier for a decisive result (team ahead wins). */
120
+ function vWin(t: number, eps: number): number {
121
+ const x = t - eps;
122
+ const denom = normCdf(x);
123
+ // Numerical floor: for an all-but-impossible upset the ratio underflows; fall back to the limit −x.
124
+ return denom > 1e-50 ? normPdf(x) / denom : -x;
125
+ }
126
+
127
+ /** Variance multiplier for a decisive result; `w = v·(v + (t − eps))`. */
128
+ function wWin(t: number, eps: number): number {
129
+ const x = t - eps;
130
+ const v = vWin(t, eps);
131
+ return v * (v + x);
132
+ }
133
+
134
+ /** Mean multiplier for a draw (signed by which team was favored). */
135
+ function vDraw(t: number, eps: number): number {
136
+ const absT = Math.abs(t);
137
+ const a = eps - absT;
138
+ const b = -eps - absT;
139
+ const denom = normCdf(a) - normCdf(b);
140
+ const numer = normPdf(b) - normPdf(a);
141
+ const magnitude = denom > 1e-50 ? numer / denom : a;
142
+ return magnitude * (t < 0 ? -1 : 1);
143
+ }
144
+
145
+ /** Variance multiplier for a draw. */
146
+ function wDraw(t: number, eps: number): number {
147
+ const absT = Math.abs(t);
148
+ const a = eps - absT;
149
+ const b = -eps - absT;
150
+ const denom = normCdf(a) - normCdf(b);
151
+ if (denom <= 1e-50) return 1;
152
+ const v = vDraw(absT, eps); // magnitude only; sign is irrelevant once squared
153
+ return v * v + (a * normPdf(a) - b * normPdf(b)) / denom;
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------------------------------
157
+ // Schemas
158
+ // ---------------------------------------------------------------------------------------------------
159
+
160
+ /**
161
+ * TrueSkill tuning: the Gaussian priors and dynamics that govern how one game's result shifts each
162
+ * player's μ (mean skill) and σ (uncertainty). Every field defaults to the classic TrueSkill values, so
163
+ * `TrueSkillParams.parse({})` yields a working configuration.
164
+ */
165
+ export const TrueSkillParams = z
166
+ .object({
167
+ mu: z
168
+ .number()
169
+ .default(25)
170
+ .describe("Prior skill mean μ₀ for a newcomer — the center of the rating scale (classic 25)."),
171
+ sigma: z
172
+ .number()
173
+ .positive()
174
+ .default(25 / 3)
175
+ .describe("Prior skill standard deviation σ₀ — a newcomer's uncertainty (25/3 ≈ 8.333, so μ−3σ starts at 0)."),
176
+ beta: z
177
+ .number()
178
+ .positive()
179
+ .default(25 / 6)
180
+ .describe(
181
+ "Performance width β — the skill gap worth ~76% win odds; larger β makes a single game move ratings less.",
182
+ ),
183
+ tau: z
184
+ .number()
185
+ .nonnegative()
186
+ .default(25 / 300)
187
+ .describe("Dynamics factor τ — variance added back each game so ratings stay responsive; 0 freezes uncertainty."),
188
+ drawProbability: z
189
+ .number()
190
+ .min(0)
191
+ .lt(1)
192
+ .default(0.1)
193
+ .describe("Assumed draw likelihood — sets the tie margin ε; 0 treats draws as impossible."),
194
+ })
195
+ .describe(
196
+ "TrueSkill tuning: Gaussian priors (μ, σ) and dynamics (β, τ, draw rate) controlling how a result moves each player's belief.",
197
+ );
198
+ export type TrueSkillParams = z.output<typeof TrueSkillParams>;
199
+
200
+ /**
201
+ * A player's TrueSkill belief: a Gaussian over their true skill, persisted between games as a mean and a
202
+ * standard deviation. The conservative, matchmaking-comparable skill is `μ − 3σ`.
203
+ */
204
+ export const TrueSkillState = z
205
+ .object({
206
+ mu: z.number().describe("Mean skill estimate μ — the center of the player's current skill belief."),
207
+ sigma: z.number().describe("Skill uncertainty σ (standard deviation) — shrinks as the player accumulates games."),
208
+ })
209
+ .describe("A player's TrueSkill belief N(μ, σ²); conservative skill is μ − 3σ.");
210
+ export type TrueSkillState = z.output<typeof TrueSkillState>;
211
+
212
+ // ---------------------------------------------------------------------------------------------------
213
+ // Algorithm
214
+ // ---------------------------------------------------------------------------------------------------
215
+
216
+ interface TrueSkillPlayer {
217
+ playerId: string;
218
+ mu: number;
219
+ /** Post-dynamics variance σ² + τ², computed once and reused across every pairwise comparison. */
220
+ varDyn: number;
221
+ }
222
+
223
+ interface TrueSkillTeam {
224
+ /** Shared finishing place (lower is better); the min across members. */
225
+ rank: number;
226
+ members: TrueSkillPlayer[];
227
+ /** Team mean = Σ member μ. */
228
+ mu: number;
229
+ /** Team variance = Σ member (σ² + τ²). */
230
+ varDyn: number;
231
+ }
232
+
233
+ /**
234
+ * TrueSkill: rate any format from a single result. `minPlayers` 2, `maxPlayers` unbounded, teams
235
+ * supported. See the module doc for the two-team update and the adjacent-pair approximation for 3+ teams.
236
+ */
237
+ export const trueskill: RatingAlgorithm<TrueSkillState, TrueSkillParams> = {
238
+ id: "trueskill",
239
+ params: TrueSkillParams,
240
+ state: TrueSkillState,
241
+ minPlayers: 2,
242
+ maxPlayers: Number.POSITIVE_INFINITY,
243
+ supportsTeams: true,
244
+
245
+ initial(params) {
246
+ return { mu: params.mu, sigma: params.sigma };
247
+ },
248
+
249
+ /** Conservative skill μ − 3σ: the value the player is ~99.7% likely to exceed. */
250
+ skill(_params, state) {
251
+ return state.mu - 3 * state.sigma;
252
+ },
253
+
254
+ update(params, entries, outcome) {
255
+ const betaSq = params.beta * params.beta;
256
+ const tauSq = params.tau * params.tau;
257
+ // Draw margin in raw skill units; n = 2 per pairwise team comparison. Standardized per pair by /c.
258
+ const drawMargin = normPpf((params.drawProbability + 1) / 2) * Math.SQRT2 * params.beta;
259
+
260
+ // Index players with their once-computed post-dynamics variance, and seed the delta accumulators.
261
+ const players = new Map<string, TrueSkillPlayer>();
262
+ const deltaMu = new Map<string, number>();
263
+ const varFactor = new Map<string, number>();
264
+ for (const entry of entries) {
265
+ const player: TrueSkillPlayer = {
266
+ playerId: entry.playerId,
267
+ mu: entry.state.mu,
268
+ varDyn: entry.state.sigma * entry.state.sigma + tauSq,
269
+ };
270
+ players.set(entry.playerId, player);
271
+ deltaMu.set(entry.playerId, 0);
272
+ varFactor.set(entry.playerId, 1);
273
+ }
274
+
275
+ // Group players into teams (solo teams when no `teams` grouping is given). Members share a place.
276
+ const teams = new Map<string, TrueSkillTeam>();
277
+ for (const entry of entries) {
278
+ const player = players.get(entry.playerId);
279
+ const rank = outcome.ranks[entry.playerId];
280
+ if (player === undefined || rank === undefined) {
281
+ throw new InternalError({
282
+ detail: `TrueSkill: entered player ${entry.playerId} is missing a finishing place in the outcome.`,
283
+ });
284
+ }
285
+ const teamId = outcome.teams?.[entry.playerId] ?? entry.playerId;
286
+ let team = teams.get(teamId);
287
+ if (team === undefined) {
288
+ team = { rank: Number.POSITIVE_INFINITY, members: [], mu: 0, varDyn: 0 };
289
+ teams.set(teamId, team);
290
+ }
291
+ team.members.push(player);
292
+ team.mu += player.mu;
293
+ team.varDyn += player.varDyn;
294
+ team.rank = Math.min(team.rank, rank);
295
+ }
296
+
297
+ // Sort teams best-first and update each adjacent pair as an independent two-team comparison,
298
+ // accumulating μ-deltas (summed) and σ²-factors (multiplied) per player off their original states.
299
+ const ordered = [...teams.values()].sort((first, second) => first.rank - second.rank);
300
+ for (let i = 0; i < ordered.length - 1; i++) {
301
+ const hi = ordered[i];
302
+ const lo = ordered[i + 1];
303
+ if (hi === undefined || lo === undefined) continue;
304
+ const isDraw = hi.rank === lo.rank;
305
+ const cSq = hi.varDyn + lo.varDyn + 2 * betaSq;
306
+ const c = Math.sqrt(cSq);
307
+ const t = (hi.mu - lo.mu) / c;
308
+ const eps = drawMargin / c;
309
+ const v = isDraw ? vDraw(t, eps) : vWin(t, eps);
310
+ const w = isDraw ? wDraw(t, eps) : wWin(t, eps);
311
+
312
+ for (const member of hi.members) {
313
+ deltaMu.set(member.playerId, (deltaMu.get(member.playerId) ?? 0) + (member.varDyn / c) * v);
314
+ varFactor.set(member.playerId, (varFactor.get(member.playerId) ?? 1) * (1 - (member.varDyn / cSq) * w));
315
+ }
316
+ for (const member of lo.members) {
317
+ deltaMu.set(member.playerId, (deltaMu.get(member.playerId) ?? 0) - (member.varDyn / c) * v);
318
+ varFactor.set(member.playerId, (varFactor.get(member.playerId) ?? 1) * (1 - (member.varDyn / cSq) * w));
319
+ }
320
+ }
321
+
322
+ const result: Record<string, TrueSkillState> = {};
323
+ for (const player of players.values()) {
324
+ const newVar = player.varDyn * (varFactor.get(player.playerId) ?? 1);
325
+ result[player.playerId] = {
326
+ mu: player.mu + (deltaMu.get(player.playerId) ?? 0),
327
+ sigma: Math.sqrt(Math.max(newVar, 0)),
328
+ };
329
+ }
330
+ return result;
331
+ },
332
+ };
@@ -0,0 +1,19 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { elo } from "./builtins/elo";
5
+ import { glicko } from "./builtins/glicko";
6
+ import { trueskill } from "./builtins/trueskill";
7
+ import { registerRatingAlgorithm } from "./registry";
8
+
9
+ /**
10
+ * The three built-in rating algorithms, registered at module load — the exact mirror of multiplayer's
11
+ * `game/builtins.ts`. Importing this module (the capability does, once) makes `elo`, `glicko`, and
12
+ * `trueskill` resolvable everywhere before the first request. An adopter registers their own the same way
13
+ * with `registerRatingAlgorithm`.
14
+ */
15
+ export const BUILT_IN_ALGORITHMS = [elo, glicko, trueskill];
16
+
17
+ for (const algorithm of BUILT_IN_ALGORITHMS) {
18
+ registerRatingAlgorithm(algorithm);
19
+ }
@@ -0,0 +1,35 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { RatingAlgorithm } from "./algorithm";
5
+
6
+ /**
7
+ * The rating-algorithm registry: `id` → its {@link RatingAlgorithm}. Populated with the built-ins
8
+ * (`algorithm/builtins`) and any {@link registerRatingAlgorithm} an adopter adds. A byte-for-byte mirror
9
+ * of multiplayer's game-model registry (`game/model.ts`) — the same load-time, last-write-wins shape.
10
+ */
11
+ const registry = new Map<string, RatingAlgorithm>();
12
+
13
+ /**
14
+ * Register a rating algorithm, making its `id` resolvable everywhere. Called at module load — the worker
15
+ * entry imports the built-ins so every id is present before the first request. Re-registering an `id`
16
+ * replaces it (last write wins), which is what lets an adopter override a built-in's tuning.
17
+ */
18
+ export function registerRatingAlgorithm(algorithm: RatingAlgorithm): void {
19
+ registry.set(algorithm.id, algorithm);
20
+ }
21
+
22
+ /** The algorithm for an `id`, or undefined if none is registered. */
23
+ export function resolveAlgorithm(id: string): RatingAlgorithm | undefined {
24
+ return registry.get(id);
25
+ }
26
+
27
+ /** Every registered algorithm's `id`, for config validation and error messages. */
28
+ export function registeredAlgorithmIds(): string[] {
29
+ return [...registry.keys()];
30
+ }
31
+
32
+ /** The fewest / most players an algorithm supports — the bounds config checks a game's roster against. */
33
+ export function algorithmBounds(algorithm: RatingAlgorithm): { min: number; max: number } {
34
+ return { min: algorithm.minPlayers, max: algorithm.maxPlayers };
35
+ }
@@ -0,0 +1,77 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import "./algorithm/builtins";
5
+ import type { BindingSpecInput } from "@pithy-sh/core/src/capability/bindings";
6
+ import { type Capability, defineCapability } from "@pithy-sh/core/src/capability/capability";
7
+ import type { Migration } from "kysely/migration";
8
+ import { RatingConfig, type RatingConfigInput, type ResolvedRatingGame, validateRatingGames } from "./config/config";
9
+ import { ratingTables } from "./data/tables";
10
+ import { registerRatingRoutes } from "./http/routes";
11
+ import { rating_0001_rating } from "./migrations/0001_rating";
12
+ import { ratingExampleSeed } from "./seeds/example";
13
+ import { PACKAGE_VERSION } from "./version.generated";
14
+
15
+ /**
16
+ * Where rating's migrations sort in the app database. Unique per database; the registry composes keys like
17
+ * `0600_rating_0001_rating`. Sits after multiplayer (500).
18
+ */
19
+ export const RATING_MIGRATION_ORDER = 600;
20
+
21
+ export type RatingOptions = RatingConfigInput & {
22
+ /** Mount the routes somewhere other than `/rating`. */
23
+ basePath?: string;
24
+ };
25
+
26
+ export interface RatingCapability extends Capability {
27
+ ratingConfig: RatingConfig;
28
+ ratingGames: ResolvedRatingGame[];
29
+ }
30
+
31
+ /**
32
+ * The rating capability: a per-player store of two distinct numbers — a skill rating (MMR, the
33
+ * matchmaking input, up and down and hideable) and a monotonic experience total (XP, the visible
34
+ * progression) — across named pools, with a pluggable rating algorithm per game.
35
+ *
36
+ * Fully optional. Config, migrations, routes, and the `DB` binding arrive only on `pithy add rating`.
37
+ *
38
+ * `dependsOn` is deliberately empty. Auth is a seam, not a peer: the routes read `c.var.auth` through
39
+ * core's `AuthContext`, so without `@pithy-sh/auth` installed every route is denied rather than open —
40
+ * the right failure, needing no dependency edge (the leaderboard pattern). The built-in algorithms
41
+ * register on import; wiring a 1v1-only algorithm to an N-player game fails here, at assembly.
42
+ */
43
+ export function rating(options: RatingOptions = { games: [] }): RatingCapability {
44
+ const { basePath, ...configInput } = options;
45
+ const resolved = RatingConfig.parse(configInput);
46
+ // Resolves every game's algorithm and rejects an unknown algorithm, an out-of-range roster, a team
47
+ // format on a non-team algorithm, or bad `algoParams` — on deploy, not on the first recorded game.
48
+ const games = validateRatingGames(resolved);
49
+
50
+ const migrations: Record<string, Migration> = { "0001_rating": rating_0001_rating };
51
+ const requiredBindings: BindingSpecInput[] = [{ type: "d1", name: "DB" }];
52
+
53
+ const capability = defineCapability({
54
+ name: "rating",
55
+ // The package version this capability ships at, stamped by `scripts/stampVersions.ts` — a Worker
56
+ // cannot read its own package.json. Reported per capability by the control-plane manifest.
57
+ version: PACKAGE_VERSION,
58
+ requiredBindings,
59
+ config: RatingConfig,
60
+ databases: {
61
+ app: {
62
+ binding: "DB",
63
+ tables: ratingTables(),
64
+ migrationOrder: RATING_MIGRATION_ORDER,
65
+ migrations,
66
+ },
67
+ },
68
+ routes: registerRatingRoutes({ games, config: resolved, basePath }),
69
+ seeds: [ratingExampleSeed],
70
+ });
71
+
72
+ return Object.assign(capability, { ratingConfig: resolved, ratingGames: games });
73
+ }
74
+
75
+ export function isRatingCapability(c: Capability): c is RatingCapability {
76
+ return c.name === "rating" && "ratingConfig" in c;
77
+ }
@@ -0,0 +1,14 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /// <reference types="@cloudflare/vitest-plugin/types" />
5
+
6
+ // Bindings the Workers-runtime test project provides to `*.workers.test.ts`, matching the Miniflare
7
+ // config in `vitest.workers.config.ts`: the app `DB` database the `pithy_rating_*` tables live in.
8
+ // `cloudflare:test` types its `env` as `Cloudflare.Env`, so test bindings are declared by augmenting
9
+ // that interface.
10
+ declare namespace Cloudflare {
11
+ interface Env {
12
+ DB: D1Database;
13
+ }
14
+ }