@pipeworx/mcp-cricsheet 0.0.0-stage → 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mojibake Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,202 @@
1
- # Temporary Holding Version
1
+ # @pipeworx/cricsheet
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Ball-by-ball cricket data collapsed to the questions people actually ask: a
4
+ match's result and scorecard, a player's career batting/bowling aggregates,
5
+ and a head-to-head record between two teams. Covers Test matches, One Day
6
+ Internationals, T20 Internationals, and the Indian Premier League. Fleet
7
+ #2975.
8
+
9
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1784+ live data sources. This is an independent, unofficial integration — not affiliated with, endorsed by, or published by the upstream provider.
10
+
11
+ ## Tools
12
+
13
+ - `cricsheet_match(match_id)` — one match's full result plus, for every
14
+ innings, the batting card (each batter's runs/balls/4s/6s/how out) and
15
+ bowling card (each bowler's overs/runs/wickets). Use
16
+ `cricsheet_find_matches` first to get a `match_id`.
17
+ - `cricsheet_player_stats(player, format?)` — a cricketer's career batting
18
+ and bowling aggregates (matches, runs, average, strike rate, 100s/50s;
19
+ wickets, bowling average, economy). `player` is matched case-insensitively
20
+ as a substring against the short-form names this pack stores (e.g. "Kohli"
21
+ for "V Kohli"); an ambiguous query returns a candidate list instead of
22
+ guessing. Optionally scope to one `format`.
23
+ - `cricsheet_head_to_head(team_a, team_b, format?, limit?, offset?)` — total
24
+ matches, each team's wins, ties/no-results, and the most recent matches
25
+ with their results. Team names must match Cricsheet's own strings (e.g.
26
+ `"India"`, `"Mumbai Indians"`).
27
+ - `cricsheet_find_matches(team?, format?, season?, venue?, player?, limit?, offset?)`
28
+ — search the match index to discover `match_id`s. With no filters, returns
29
+ the most recent matches held across every format.
30
+
31
+ `format` accepts `"Test"`, `"ODI"`, `"T20I"` (international T20), or `"IPL"`
32
+ (case-insensitive); omit it to search across all four.
33
+
34
+ **What this pack does not do:** it does not return individual deliveries
35
+ (ball-by-ball). Every match file is parsed ball-by-ball once, at load time,
36
+ and collapsed into match / innings / batting-card / bowling-card rows —
37
+ storing every ball for ~11,000 matches would multiply the row count by
38
+ roughly 150-200x for a question ("who scored what, who took which wickets")
39
+ that the scorecard already answers.
40
+
41
+ ## Auth
42
+
43
+ Keyless. The tools answer only through the Pipeworx gateway, which holds the
44
+ data; a standalone `npm install` has no data to read, and every tool throws
45
+ a clear "not bound in this runtime" error there instead of returning an
46
+ empty list.
47
+
48
+ ## Data sources
49
+
50
+ - Cricsheet — ball-by-ball match data for Test cricket, One Day
51
+ Internationals, T20 Internationals, and the Indian Premier League.
52
+ **Licence: ODC Attribution License (ODC-BY)** — free to share, adapt and
53
+ use (including commercially) with attribution to Cricsheet. Cricsheet
54
+ publishes bulk per-match files only (no live API), so this pack builds a
55
+ copy (`scripts/cricsheet/load-data.mjs`) into a D1 database rather than
56
+ proxying per request, per CLAUDE.md's "build the copy" rule for a
57
+ bulk-only source.
58
+
59
+ ### Known gaps — read before extending this pack
60
+
61
+ **Only four of Cricsheet's ~60 competitions are loaded.** Cricsheet also
62
+ publishes first-class, List A, and ~50 domestic T20 leagues (Big Bash, T20
63
+ Blast, Pakistan Super League, Caribbean Premier League, The Hundred, and so
64
+ on) as separate bulk files. This pack loads the four with the clearest
65
+ cross-format demand (international Test/ODI/T20I plus the IPL, the league
66
+ named explicitly in the originating demand signal). Adding another league is
67
+ one more entry in `BULK_FILES` in the loader, using the same
68
+ match-type-agnostic parser.
69
+
70
+ **Women's matches are included wherever Cricsheet's own bulk file includes
71
+ them**, distinguished by the `gender` field Cricsheet carries per match — not
72
+ filtered out, not separately tooled.
73
+
74
+ **Player identity is the raw name string Cricsheet uses** (e.g. "V Kohli",
75
+ "SR Watson") — short forms, not full names, and this pack does not maintain
76
+ its own name-to-person mapping beyond what `cricsheet_player_stats`'s
77
+ substring match provides. Two different people sharing a short-form name
78
+ (rare, but not impossible across ~8,400 distinct names) would merge under
79
+ that string; nothing in Cricsheet's own files beyond the registry ID (not
80
+ currently loaded) resolves that.
81
+
82
+ **Super overs are stored as extra innings entries**, exactly as Cricsheet's
83
+ own `innings` array represents them (a tied T20 can have 3 or 4 innings
84
+ entries) — `cricsheet_match` returns whatever innings Cricsheet's file has,
85
+ including super-over ones.
86
+
87
+ ### Refreshing or extending the data
88
+
89
+ Re-run `node scripts/cricsheet/load-data.mjs` (needs `wrangler` logged in to
90
+ the Pipeworx Cloudflare account, and `CLOUDFLARE_ACCOUNT_ID` set when more
91
+ than one account is visible) — it re-fetches every format live and replaces
92
+ every table (DROP + reload), because Cricsheet corrects historical files
93
+ rather than versioning them. `--only <Test|ODI|T20I|IPL>` reloads a single
94
+ format; `--dry-run` parses and prints counts without touching D1, useful
95
+ after changing the parser. Cricsheet adds new matches continuously (for
96
+ international and IPL cricket, typically within a day of the match); re-run
97
+ the loader periodically to pick those up — `data_as_of` in every tool
98
+ response names the load it is reading.
99
+
100
+ ## Quick Start
101
+
102
+ Add to your MCP client (Claude Desktop, Cursor, Windsurf, etc.):
103
+
104
+ ```json
105
+ {
106
+ "mcpServers": {
107
+ "cricsheet": {
108
+ "url": "https://gateway.pipeworx.io/cricsheet/mcp"
109
+ }
110
+ }
111
+ }
112
+ ```
113
+
114
+ ### What this endpoint actually serves
115
+
116
+ `tools/list` at `https://gateway.pipeworx.io/cricsheet/mcp` returns the tools in the table
117
+ above **plus the shared Pipeworx meta-tools** — `ask_pipeworx`,
118
+ `discover_tools`, `search_within`, `remember`/`recall` and the rest of the
119
+ gateway-wide set. So the tool count you see is larger than this table: a
120
+ single-pack endpoint currently lists roughly 30 shared tools alongside the
121
+ pack's own. The connection's `initialize` response states its exact scope, and
122
+ is the authoritative answer for a given day.
123
+
124
+ This is deliberate, not multiplexing by accident. The meta-tools are what let a
125
+ scoped connection answer a question this pack does not cover — via
126
+ `ask_pipeworx`, which routes across the whole catalog — without you adding a
127
+ second MCP server. There is currently no way to mount a pack endpoint without
128
+ them; if the extra schemas cost you more context than the routing is worth,
129
+ connect to the full gateway once rather than to several pack endpoints.
130
+
131
+ Or connect to the full Pipeworx gateway to get every pack's tools listed
132
+ directly, instead of just this one's:
133
+
134
+ ```json
135
+ {
136
+ "mcpServers": {
137
+ "pipeworx": {
138
+ "url": "https://gateway.pipeworx.io/mcp"
139
+ }
140
+ }
141
+ }
142
+ ```
143
+
144
+ Both URLs reach the same gateway and the same 1784+ data sources. The
145
+ only difference is which pack's tools are listed **directly**; `ask_pipeworx`
146
+ reaches all of them from either one.
147
+
148
+ ## No MCP client? Call it over HTTP
149
+
150
+ ```bash
151
+ curl -X POST https://gateway.pipeworx.io/v1/tools/cricsheet_match \
152
+ -H 'Content-Type: application/json' \
153
+ -d '{"match_id":1082591}'
154
+ ```
155
+
156
+ No account needed for the first calls. Inspect any tool: `GET https://gateway.pipeworx.io/v1/tools/cricsheet_match`. Find one: `POST https://gateway.pipeworx.io/v1/tools/search_packs` with `{"query":"..."}`.
157
+
158
+ ## Standalone (no gateway account)
159
+
160
+ This package also runs as a local stdio MCP server — no Pipeworx account, no
161
+ gateway round-trip:
162
+
163
+ ```json
164
+ {
165
+ "mcpServers": {
166
+ "cricsheet": {
167
+ "command": "npx",
168
+ "args": ["-y", "@pipeworx/mcp-cricsheet"]
169
+ }
170
+ }
171
+ }
172
+ ```
173
+
174
+ Or run it directly to confirm it starts:
175
+
176
+ ```bash
177
+ npx -y @pipeworx/mcp-cricsheet
178
+ ```
179
+
180
+ It speaks MCP over stdin/stdout and answers `initialize`/`tools/list`/`tools/call`
181
+ for **only** this pack's tools — none of the shared meta-tools the gateway
182
+ connection above adds. Same source, same tools, no ask_pipeworx routing.
183
+
184
+ ## Using with ask_pipeworx
185
+
186
+ Instead of calling tools directly, you can ask questions in plain English —
187
+ this works on the pack endpoint above as well as on the full gateway:
188
+
189
+ ```
190
+ ask_pipeworx({ question: "your question about Cricsheet data" })
191
+ ```
192
+
193
+ The gateway picks the right tool and fills the arguments automatically.
194
+
195
+ ## More
196
+
197
+ - [Docs and guides](https://pipeworx.io/docs)
198
+ - [pipeworx.io](https://pipeworx.io)
199
+
200
+ ## License
201
+
202
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // Entry point for `npx @pipeworx/mcp-<slug>`.
4
+ //
5
+ // Packs ship as raw TypeScript (no build step — see publish-pack.sh for why:
6
+ // tsx sidesteps every extensionless-import / bare-JSON-import edge case a
7
+ // per-pack tsc build would have to solve one pack at a time). This file
8
+ // registers tsx's ESM loader programmatically, then hands off to src/server.ts,
9
+ // which wraps the pack's {tools, callTool} export in a stdio MCP server.
10
+ //
11
+ // Copied verbatim into every published pack repo by scripts/publish-pack.sh —
12
+ // edit this file, not a per-pack copy.
13
+ import { register } from 'tsx/esm/api';
14
+
15
+ register();
16
+
17
+ await import('../src/server.ts');
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@pipeworx/mcp-cricsheet",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "cricsheet -- ball-by-ball cricket data collapsed to match / player / team questions (fleet #2975).",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "bin": {
9
+ "mcp-cricsheet": "bin/cli.js"
10
+ },
11
+ "keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "cricsheet"],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/pipeworx-io/mcp-cricsheet.git"
16
+ },
17
+ "scripts": {
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "^1.30.0",
22
+ "tsx": "^4.19.0"
23
+ },
24
+ "devDependencies": {
25
+ "typescript": "^5.9.3",
26
+ "@cloudflare/workers-types": "^4.20260405.1"
27
+ },
28
+ "pipeworx": {
29
+ "sourceHash": "v1-a408bcb18921b17d5b636ede895773b52861f20e397029fca5407c02b9210876",
30
+ "sourceCommit": "3723e46c6d115bd4a1fd2175e6bf758555e7cb50"
31
+ }
32
+ }
package/server.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.pipeworx-io/cricsheet",
4
+ "title": "Cricsheet",
5
+ "description": "cricsheet -- ball-by-ball cricket data collapsed to match / player / team questions (fleet #2975).",
6
+ "version": "0.1.0",
7
+ "websiteUrl": "https://pipeworx.io/packs/cricsheet",
8
+ "repository": {
9
+ "url": "https://github.com/pipeworx-io/mcp-cricsheet",
10
+ "source": "github"
11
+ },
12
+ "remotes": [
13
+ {
14
+ "type": "streamable-http",
15
+ "url": "https://gateway.pipeworx.io/cricsheet/mcp"
16
+ }
17
+ ]
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,423 @@
1
+ interface McpToolDefinition {
2
+ name: string;
3
+ description: string;
4
+ /** Human-facing one-liner (fleet #1967). Optional; consumers fall back to
5
+ * description. Kept in step with shared/src/types.ts — scripts/lib/
6
+ * check-inlined-types.mjs reports drift at publish time. */
7
+ summary?: string;
8
+ inputSchema: {
9
+ type: 'object';
10
+ properties: Record<string, unknown>;
11
+ required?: string[];
12
+ anyOf?: Array<{ required: string[] }>;
13
+ oneOf?: Array<{ required: string[] }>;
14
+ allOf?: Array<{ required: string[] }>;
15
+ };
16
+ outputSchema?: Record<string, unknown>;
17
+ }
18
+
19
+ interface McpToolExport {
20
+ tools: McpToolDefinition[];
21
+ callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
22
+ meter?: { credits: number };
23
+ cost?: Record<string, unknown>;
24
+ provider?: string;
25
+ }
26
+
27
+ /**
28
+ * cricsheet -- ball-by-ball cricket data collapsed to match / player / team
29
+ * questions (fleet #2975).
30
+ *
31
+ * Data: Cricsheet (cricsheet.org) -- per-match JSON files for Test, One Day
32
+ * International and Twenty20 International cricket plus the Indian Premier
33
+ * League (ODC Attribution License, ODC-BY; attribution: Cricsheet). Cricsheet
34
+ * publishes bulk files only, so this pack builds a copy
35
+ * (scripts/cricsheet/load-data.mjs) into a D1 database the gateway hands it
36
+ * as `_d1` (pack-manifest "d1Binding": "CRICSHEET_DB"). Design note: this
37
+ * file + store.ts.
38
+ *
39
+ * Every match file is parsed ball-by-ball IN THE LOADER and collapsed to
40
+ * match, innings, and batting/bowling scorecard rows -- individual
41
+ * deliveries are not stored or served (CLAUDE.md "prefer the smallest store
42
+ * that answers the tool questions; do not store every ball if aggregates
43
+ * answer the questions").
44
+ *
45
+ * Every response carries `source`, `source_url`, `license` and `data_as_of`.
46
+ */
47
+ import {
48
+ battingAggregate,
49
+ bowlingAggregate,
50
+ dataAsOf,
51
+ findMatches,
52
+ findMatchesCount,
53
+ getBattingCards,
54
+ getBowlingCards,
55
+ getInnings,
56
+ getMatch,
57
+ headToHeadCount,
58
+ headToHeadMatches,
59
+ requireDb,
60
+ resolveFormat,
61
+ resolvePlayer,
62
+ type D1Like,
63
+ type Format,
64
+ } from './store.js';
65
+
66
+ const SOURCE = 'Cricsheet, ball-by-ball cricket match data';
67
+ const SOURCE_URL = 'https://cricsheet.org';
68
+ const LICENSE = 'ODC-BY (Open Data Commons Attribution License); attribution: Cricsheet';
69
+
70
+ const FORMAT_DESC = 'Match format: "Test", "ODI", "T20I" (international T20), or "IPL". Omit to search across every format this pack holds.';
71
+
72
+ const tools: McpToolExport['tools'] = [
73
+ {
74
+ name: 'cricsheet_match',
75
+ description:
76
+ 'Full result and scorecard for one cricket match: teams, venue, date, toss, result, and for every innings the batting card (each batter\'s runs/balls/4s/6s/how out) and bowling card (each bowler\'s overs/runs/wickets). Covers Test, ODI and T20 International cricket plus the IPL (Cricsheet, ODC-BY). Use cricsheet_find_matches first to get a match_id if you don\'t already have one.',
77
+ summary: "One match's full result and batting/bowling scorecards by match_id.",
78
+ inputSchema: {
79
+ type: 'object' as const,
80
+ properties: {
81
+ match_id: { type: 'number', description: 'Cricsheet match id, e.g. 1082591. Get one from cricsheet_find_matches.' },
82
+ },
83
+ required: ['match_id'],
84
+ },
85
+ },
86
+ {
87
+ name: 'cricsheet_player_stats',
88
+ description:
89
+ 'A cricketer\'s career batting and bowling aggregates (matches, runs, average, strike rate, 100s/50s; wickets, bowling average, economy) from Cricsheet\'s Test/ODI/T20I/IPL match data. Pass a name or partial name ("Kohli", "V Kohli") -- Cricsheet stores short-form names, so this matches case-insensitively and returns a candidate list if the name is ambiguous. Optionally scope to one format with `format`. Covers only matches Cricsheet has scored ball-by-ball, not a player\'s entire career.',
90
+ summary: "A cricketer's batting/bowling career aggregates, optionally scoped to one format.",
91
+ inputSchema: {
92
+ type: 'object' as const,
93
+ properties: {
94
+ player: { type: 'string', description: 'Player name or partial name, e.g. "Kohli" or "V Kohli".' },
95
+ format: { type: 'string', description: FORMAT_DESC },
96
+ },
97
+ required: ['player'],
98
+ },
99
+ },
100
+ {
101
+ name: 'cricsheet_head_to_head',
102
+ description:
103
+ 'Head-to-head record between two cricket teams from Cricsheet\'s Test/ODI/T20I/IPL match data: total matches, each team\'s wins, ties/no-results, and the most recent matches with their results. Pass team names as Cricsheet uses them, e.g. "India", "Australia", "Mumbai Indians" (IPL). Optionally scope to one format with `format`.',
104
+ summary: 'Win/loss head-to-head record between two teams, optionally scoped to one format.',
105
+ inputSchema: {
106
+ type: 'object' as const,
107
+ properties: {
108
+ team_a: { type: 'string', description: 'First team name, e.g. "India".' },
109
+ team_b: { type: 'string', description: 'Second team name, e.g. "Australia".' },
110
+ format: { type: 'string', description: FORMAT_DESC },
111
+ limit: { type: 'number', description: 'Max recent matches to list, 1-100. Default 10.' },
112
+ offset: { type: 'number', description: 'Pagination offset into the recent-matches list. Default 0.' },
113
+ },
114
+ required: ['team_a', 'team_b'],
115
+ },
116
+ },
117
+ {
118
+ name: 'cricsheet_find_matches',
119
+ description:
120
+ 'Search Cricsheet\'s Test/ODI/T20I/IPL match index by team, format, season, venue and/or player, returning match_id plus date/teams/venue/result for each hit -- use this to discover match_ids for cricsheet_match. With no filters, returns the most recent matches across every format. Provide at least one filter to narrow a specific search.',
121
+ summary: 'Search matches by team/format/season/venue/player to get match_ids.',
122
+ inputSchema: {
123
+ type: 'object' as const,
124
+ properties: {
125
+ team: { type: 'string', description: 'Team name, e.g. "England" or "Chennai Super Kings" (IPL).' },
126
+ format: { type: 'string', description: FORMAT_DESC },
127
+ season: { type: 'string', description: 'Season label as Cricsheet records it, e.g. "2023" or "2007/08".' },
128
+ venue: { type: 'string', description: 'Venue name or partial venue name, e.g. "Lord\'s".' },
129
+ player: { type: 'string', description: 'Exact player name as Cricsheet stores it (see cricsheet_player_stats to resolve a partial name first).' },
130
+ limit: { type: 'number', description: 'Max matches to return, 1-200. Default 50.' },
131
+ offset: { type: 'number', description: 'Pagination offset. Default 0.' },
132
+ },
133
+ },
134
+ },
135
+ ];
136
+
137
+ function clampInt(v: unknown, lo: number, hi: number, d: number): number {
138
+ const n = Math.floor(Number(v));
139
+ return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d;
140
+ }
141
+ function str(v: unknown): string | null {
142
+ return v != null && v !== '' ? String(v).trim() : null;
143
+ }
144
+
145
+ function serializeMatch(m: {
146
+ match_id: number;
147
+ format: string;
148
+ gender: string;
149
+ team1: string;
150
+ team2: string;
151
+ match_date: string;
152
+ season: string | null;
153
+ venue: string | null;
154
+ city: string | null;
155
+ event_name: string | null;
156
+ toss_winner: string | null;
157
+ toss_decision: string | null;
158
+ outcome_winner: string | null;
159
+ outcome_by_runs: number | null;
160
+ outcome_by_wickets: number | null;
161
+ outcome_by_innings: number | null;
162
+ outcome_method: string | null;
163
+ result: string | null;
164
+ player_of_match: string | null;
165
+ }) {
166
+ return {
167
+ match_id: m.match_id,
168
+ format: m.format,
169
+ gender: m.gender,
170
+ team1: m.team1,
171
+ team2: m.team2,
172
+ match_date: m.match_date,
173
+ season: m.season,
174
+ venue: m.venue,
175
+ city: m.city,
176
+ event: m.event_name,
177
+ toss: m.toss_winner ? { winner: m.toss_winner, decision: m.toss_decision } : null,
178
+ outcome: {
179
+ winner: m.outcome_winner,
180
+ by_runs: m.outcome_by_runs,
181
+ by_wickets: m.outcome_by_wickets,
182
+ by_innings: m.outcome_by_innings === 1,
183
+ method: m.outcome_method,
184
+ result: m.result,
185
+ },
186
+ player_of_match: m.player_of_match,
187
+ };
188
+ }
189
+
190
+ async function cricsheetMatchTool(db: D1Like, args: Record<string, unknown>) {
191
+ const asOf = await dataAsOf(db);
192
+ const matchId = Math.floor(Number(args.match_id));
193
+ if (!Number.isFinite(matchId)) {
194
+ return { found: false, reason: 'invalid_match_id', message: 'match_id must be a number.', data_as_of: asOf };
195
+ }
196
+ const match = await getMatch(db, matchId);
197
+ if (!match) {
198
+ return {
199
+ found: false,
200
+ reason: 'not_found',
201
+ match_id: matchId,
202
+ message: `No match held with match_id=${matchId}.`,
203
+ hint: 'Call cricsheet_find_matches to search by team/format/season/venue and get a valid match_id.',
204
+ data_as_of: asOf,
205
+ };
206
+ }
207
+ const [innings, batting, bowling] = await Promise.all([getInnings(db, matchId), getBattingCards(db, matchId), getBowlingCards(db, matchId)]);
208
+ const inningsOut = innings.map((inn) => ({
209
+ innings: inn.innings_num,
210
+ batting_team: inn.batting_team,
211
+ total_runs: inn.total_runs,
212
+ total_wickets: inn.total_wickets,
213
+ overs: `${Math.floor(inn.legal_balls / 6)}.${inn.legal_balls % 6}`,
214
+ batting_card: batting
215
+ .filter((b) => b.innings_num === inn.innings_num)
216
+ .map((b) => ({
217
+ position: b.position,
218
+ player: b.player,
219
+ runs: b.runs,
220
+ balls: b.balls,
221
+ fours: b.fours,
222
+ sixes: b.sixes,
223
+ not_out: b.not_out === 1,
224
+ dismissal: b.not_out === 1 ? 'not out' : b.dismissal_kind ? `${b.dismissal_kind}${b.dismissal_bowler ? ` b ${b.dismissal_bowler}` : ''}` : null,
225
+ })),
226
+ bowling_card: bowling
227
+ .filter((b) => b.innings_num === inn.innings_num)
228
+ .map((b) => ({
229
+ order: b.order_num,
230
+ player: b.player,
231
+ overs: `${Math.floor(b.balls / 6)}.${b.balls % 6}`,
232
+ runs_conceded: b.runs_conceded,
233
+ wickets: b.wickets,
234
+ economy: b.balls > 0 ? Math.round((b.runs_conceded / (b.balls / 6)) * 100) / 100 : null,
235
+ })),
236
+ }));
237
+ return {
238
+ found: true,
239
+ ...serializeMatch(match),
240
+ innings: inningsOut,
241
+ source: SOURCE,
242
+ source_url: SOURCE_URL,
243
+ license: LICENSE,
244
+ data_as_of: asOf,
245
+ };
246
+ }
247
+
248
+ async function cricsheetPlayerStatsTool(db: D1Like, args: Record<string, unknown>) {
249
+ const asOf = await dataAsOf(db);
250
+ const query = str(args.player);
251
+ if (!query) {
252
+ return { found: false, reason: 'missing_player', message: 'player is required.', data_as_of: asOf };
253
+ }
254
+ let format: Format | null = null;
255
+ if (args.format != null && args.format !== '') {
256
+ format = resolveFormat(args.format);
257
+ if (!format) {
258
+ return { found: false, reason: 'unknown_format', message: `${JSON.stringify(args.format)} is not a recognized format. Use "Test", "ODI", "T20I", or "IPL".`, data_as_of: asOf };
259
+ }
260
+ }
261
+ const { resolved, candidates } = await resolvePlayer(db, query);
262
+ if (!resolved) {
263
+ if (!candidates.length) {
264
+ return {
265
+ found: false,
266
+ reason: 'no_match',
267
+ message: `No player matching ${JSON.stringify(query)} held in this pack.`,
268
+ hint: 'Cricsheet uses short-form names (e.g. "V Kohli", "SR Watson") -- try a surname or a shorter fragment.',
269
+ data_as_of: asOf,
270
+ };
271
+ }
272
+ return {
273
+ found: false,
274
+ reason: 'ambiguous',
275
+ message: `${JSON.stringify(query)} matches ${candidates.length} players.`,
276
+ candidates: candidates.map((c) => ({ player: c.player, matches: c.matches })),
277
+ hint: 'Call again with the exact player string from candidates.',
278
+ data_as_of: asOf,
279
+ };
280
+ }
281
+ const [batting, bowling] = await Promise.all([battingAggregate(db, resolved, format), bowlingAggregate(db, resolved, format)]);
282
+ if (batting.innings === 0 && bowling.innings === 0) {
283
+ return {
284
+ found: false,
285
+ reason: 'no_data_for_format',
286
+ player: resolved,
287
+ format: format ?? 'all',
288
+ message: `${resolved} has no ${format ?? 'Cricsheet'} innings held in this pack.`,
289
+ hint: format ? 'Try again without format to search across every format.' : undefined,
290
+ data_as_of: asOf,
291
+ };
292
+ }
293
+ return {
294
+ found: true,
295
+ player: resolved,
296
+ format: format ?? 'all',
297
+ batting,
298
+ bowling,
299
+ source: SOURCE,
300
+ source_url: SOURCE_URL,
301
+ license: LICENSE,
302
+ data_as_of: asOf,
303
+ };
304
+ }
305
+
306
+ async function cricsheetHeadToHeadTool(db: D1Like, args: Record<string, unknown>) {
307
+ const asOf = await dataAsOf(db);
308
+ const teamA = str(args.team_a);
309
+ const teamB = str(args.team_b);
310
+ if (!teamA || !teamB) {
311
+ return { found: false, reason: 'missing_team', message: 'team_a and team_b are both required.', data_as_of: asOf };
312
+ }
313
+ let format: Format | null = null;
314
+ if (args.format != null && args.format !== '') {
315
+ format = resolveFormat(args.format);
316
+ if (!format) {
317
+ return { found: false, reason: 'unknown_format', message: `${JSON.stringify(args.format)} is not a recognized format. Use "Test", "ODI", "T20I", or "IPL".`, data_as_of: asOf };
318
+ }
319
+ }
320
+ const limit = clampInt(args.limit, 1, 100, 10);
321
+ const offset = Math.max(0, Math.floor(Number(args.offset) || 0));
322
+ const filter = { team_a: teamA, team_b: teamB, format, limit, offset };
323
+ const [rows, total] = await Promise.all([headToHeadMatches(db, filter), headToHeadCount(db, filter)]);
324
+ if (!total) {
325
+ return {
326
+ found: false,
327
+ reason: 'no_match',
328
+ team_a: teamA,
329
+ team_b: teamB,
330
+ format: format ?? 'all',
331
+ message: `No matches held between ${teamA} and ${teamB}${format ? ` in ${format}` : ''}.`,
332
+ hint: 'Check team spelling (Cricsheet uses full names, e.g. "India" not "IND") or call cricsheet_find_matches with team= to see what is held.',
333
+ data_as_of: asOf,
334
+ };
335
+ }
336
+ let winsA = 0, winsB = 0, ties = 0, noResult = 0;
337
+ for (const m of rows) {
338
+ if (m.result === 'tie') ties++;
339
+ else if (m.result) noResult++;
340
+ else if (m.outcome_winner && m.outcome_winner.toLowerCase() === teamA.toLowerCase()) winsA++;
341
+ else if (m.outcome_winner && m.outcome_winner.toLowerCase() === teamB.toLowerCase()) winsB++;
342
+ }
343
+ const truncated = rows.length < total;
344
+ return {
345
+ found: true,
346
+ team_a: teamA,
347
+ team_b: teamB,
348
+ format: format ?? 'all',
349
+ total_matching: total,
350
+ returned: rows.length,
351
+ truncated,
352
+ ...(truncated ? { hint: 'More matches exist than were returned (recent-matches list only). Page with limit/offset.' } : {}),
353
+ recent_matches_summary: { wins_in_returned_set: { [teamA]: winsA, [teamB]: winsB }, ties, no_result_or_draw: noResult },
354
+ recent_matches: rows.map(serializeMatch),
355
+ source: SOURCE,
356
+ source_url: SOURCE_URL,
357
+ license: LICENSE,
358
+ data_as_of: asOf,
359
+ };
360
+ }
361
+
362
+ async function cricsheetFindMatchesTool(db: D1Like, args: Record<string, unknown>) {
363
+ const asOf = await dataAsOf(db);
364
+ let format: Format | null = null;
365
+ if (args.format != null && args.format !== '') {
366
+ format = resolveFormat(args.format);
367
+ if (!format) {
368
+ return { found: false, reason: 'unknown_format', message: `${JSON.stringify(args.format)} is not a recognized format. Use "Test", "ODI", "T20I", or "IPL".`, data_as_of: asOf };
369
+ }
370
+ }
371
+ const limit = clampInt(args.limit, 1, 200, 50);
372
+ const offset = Math.max(0, Math.floor(Number(args.offset) || 0));
373
+ const filter = {
374
+ team: str(args.team) ?? undefined,
375
+ format,
376
+ season: str(args.season) ?? undefined,
377
+ venue: str(args.venue) ?? undefined,
378
+ player: str(args.player) ?? undefined,
379
+ limit,
380
+ offset,
381
+ };
382
+ const [rows, total] = await Promise.all([findMatches(db, filter), findMatchesCount(db, filter)]);
383
+ if (!total) {
384
+ return {
385
+ found: false,
386
+ reason: 'no_match',
387
+ message: 'No matches held matching those filters.',
388
+ hint: 'Loosen a filter -- venue is substring-matched, but team/season/player require the exact string Cricsheet uses.',
389
+ data_as_of: asOf,
390
+ };
391
+ }
392
+ const truncated = rows.length < total;
393
+ return {
394
+ found: true,
395
+ total_matching: total,
396
+ returned: rows.length,
397
+ truncated,
398
+ ...(truncated ? { hint: 'More matches exist than were returned. Narrow with team/format/season/venue/player, or page with limit/offset.' } : {}),
399
+ matches: rows.map(serializeMatch),
400
+ source: SOURCE,
401
+ source_url: SOURCE_URL,
402
+ license: LICENSE,
403
+ data_as_of: asOf,
404
+ };
405
+ }
406
+
407
+ async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
408
+ const bound = requireDb(args._d1);
409
+ switch (name) {
410
+ case 'cricsheet_match':
411
+ return cricsheetMatchTool(bound, args);
412
+ case 'cricsheet_player_stats':
413
+ return cricsheetPlayerStatsTool(bound, args);
414
+ case 'cricsheet_head_to_head':
415
+ return cricsheetHeadToHeadTool(bound, args);
416
+ case 'cricsheet_find_matches':
417
+ return cricsheetFindMatchesTool(bound, args);
418
+ default:
419
+ throw new Error(`Unknown tool: ${name}`);
420
+ }
421
+ }
422
+
423
+ export default { tools, callTool, meter: { credits: 1 } } satisfies McpToolExport;
package/src/server.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Stdio MCP server entry point for @pipeworx/mcp-cricsheet.
3
+ * Generated by scripts/publish-pack.sh — do not hand-edit in the pack repo;
4
+ * edit scripts/publish-pack.sh (the server.ts heredoc) and republish instead.
5
+ */
6
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
9
+ import pack from './index.js';
10
+
11
+
12
+ const server = new Server(
13
+ { name: '@pipeworx/mcp-cricsheet', version: '0.1.0' },
14
+ { capabilities: { tools: {} } },
15
+ );
16
+
17
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
18
+ tools: pack.tools.map((t) => ({
19
+ name: t.name,
20
+ description: t.description,
21
+ inputSchema: t.inputSchema,
22
+ })),
23
+ }));
24
+
25
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
26
+ const { name, arguments: args } = request.params;
27
+ try {
28
+ const callArgs = { ...((args ?? {}) as Record<string, unknown>) };
29
+
30
+ const result = await pack.callTool(name, callArgs);
31
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
32
+ } catch (err) {
33
+ return {
34
+ content: [{ type: 'text', text: err instanceof Error ? err.message : String(err) }],
35
+ isError: true,
36
+ };
37
+ }
38
+ });
39
+
40
+ async function main() {
41
+ const transport = new StdioServerTransport();
42
+ await server.connect(transport);
43
+ }
44
+
45
+ main().catch((err) => {
46
+ console.error('Fatal error running server:', err);
47
+ process.exit(1);
48
+ });
package/src/store.ts ADDED
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Reads for cricsheet (fleet #2975).
3
+ *
4
+ * Data: Cricsheet (cricsheet.org) -- per-match ball-by-ball JSON files for
5
+ * Test, ODI and T20 International cricket plus the Indian Premier League
6
+ * (ODC Attribution License, ODC-BY). Cricsheet publishes bulk files only, so
7
+ * this pack builds a copy (scripts/cricsheet/load-data.mjs) into a D1
8
+ * database the gateway hands it as `_d1` (pack-manifest "d1Binding":
9
+ * "CRICSHEET_DB"). Each match file is parsed ball-by-ball IN THE LOADER and
10
+ * collapsed to match / innings / batting-card / bowling-card rows --
11
+ * individual deliveries are never stored, per CLAUDE.md "prefer the
12
+ * smallest store that answers the tool questions."
13
+ *
14
+ * Design note: this file + index.ts.
15
+ */
16
+
17
+ /** The slice of Cloudflare's D1Database this pack uses, declared here so the
18
+ * pack needs no workers-types and a test can pass a node:sqlite stub. */
19
+ export interface D1Like {
20
+ prepare(sql: string): D1StmtLike;
21
+ }
22
+ export interface D1StmtLike {
23
+ bind(...values: unknown[]): D1StmtLike;
24
+ all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;
25
+ first<T = Record<string, unknown>>(): Promise<T | null>;
26
+ }
27
+
28
+ export class StoreUnavailableError extends Error {
29
+ constructor() {
30
+ super(
31
+ 'cricsheet: the match store is not bound in this runtime (no _d1). These tools only answer through the Pipeworx gateway; a standalone install has no data to read.',
32
+ );
33
+ this.name = 'StoreUnavailableError';
34
+ }
35
+ }
36
+ export function requireDb(d1: unknown): D1Like {
37
+ if (!d1 || typeof (d1 as D1Like).prepare !== 'function') throw new StoreUnavailableError();
38
+ return d1 as D1Like;
39
+ }
40
+
41
+ export const FORMATS = ['Test', 'ODI', 'T20I', 'IPL'] as const;
42
+ export type Format = (typeof FORMATS)[number];
43
+
44
+ export function resolveFormat(input: unknown): Format | null {
45
+ if (input == null || input === '') return null;
46
+ const s = String(input).trim().toUpperCase();
47
+ const map: Record<string, Format> = {
48
+ TEST: 'Test', TESTS: 'Test',
49
+ ODI: 'ODI', ODIS: 'ODI', 'ONE DAY INTERNATIONAL': 'ODI', 'ONE-DAY INTERNATIONAL': 'ODI',
50
+ T20I: 'T20I', T20IS: 'T20I', T20: 'T20I', 'T20 INTERNATIONAL': 'T20I', 'T20 INTERNATIONALS': 'T20I',
51
+ IPL: 'IPL', 'INDIAN PREMIER LEAGUE': 'IPL',
52
+ };
53
+ return map[s] ?? null;
54
+ }
55
+
56
+ export async function dataAsOf(db: D1Like): Promise<string | null> {
57
+ const r = await db.prepare(`SELECT value FROM cricsheet_meta WHERE key = 'loaded_at'`).first<{ value: string }>();
58
+ return r?.value ?? null;
59
+ }
60
+
61
+ export interface MatchRow {
62
+ match_id: number;
63
+ format: string;
64
+ gender: string;
65
+ team1: string;
66
+ team2: string;
67
+ match_date: string;
68
+ season: string | null;
69
+ venue: string | null;
70
+ city: string | null;
71
+ event_name: string | null;
72
+ toss_winner: string | null;
73
+ toss_decision: string | null;
74
+ outcome_winner: string | null;
75
+ outcome_by_runs: number | null;
76
+ outcome_by_wickets: number | null;
77
+ outcome_by_innings: number | null;
78
+ outcome_method: string | null;
79
+ result: string | null;
80
+ player_of_match: string | null;
81
+ }
82
+
83
+ export async function getMatch(db: D1Like, matchId: number): Promise<MatchRow | null> {
84
+ return db
85
+ .prepare(
86
+ `SELECT match_id, format, gender, team1, team2, match_date, season, venue, city, event_name,
87
+ toss_winner, toss_decision, outcome_winner, outcome_by_runs, outcome_by_wickets,
88
+ outcome_by_innings, outcome_method, result, player_of_match
89
+ FROM matches WHERE match_id = ?`,
90
+ )
91
+ .bind(matchId)
92
+ .first<MatchRow>();
93
+ }
94
+
95
+ export interface InningsRow {
96
+ innings_num: number;
97
+ batting_team: string;
98
+ total_runs: number;
99
+ total_wickets: number;
100
+ legal_balls: number;
101
+ }
102
+ export async function getInnings(db: D1Like, matchId: number): Promise<InningsRow[]> {
103
+ const r = await db
104
+ .prepare(`SELECT innings_num, batting_team, total_runs, total_wickets, legal_balls FROM innings WHERE match_id = ? ORDER BY innings_num`)
105
+ .bind(matchId)
106
+ .all<InningsRow>();
107
+ return r.results;
108
+ }
109
+
110
+ export interface BattingCardRow {
111
+ innings_num: number;
112
+ position: number;
113
+ player: string;
114
+ runs: number;
115
+ balls: number;
116
+ fours: number;
117
+ sixes: number;
118
+ not_out: number;
119
+ dismissal_kind: string | null;
120
+ dismissal_bowler: string | null;
121
+ }
122
+ export async function getBattingCards(db: D1Like, matchId: number): Promise<BattingCardRow[]> {
123
+ const r = await db
124
+ .prepare(
125
+ `SELECT innings_num, position, player, runs, balls, fours, sixes, not_out, dismissal_kind, dismissal_bowler
126
+ FROM batting_cards WHERE match_id = ? ORDER BY innings_num, position`,
127
+ )
128
+ .bind(matchId)
129
+ .all<BattingCardRow>();
130
+ return r.results;
131
+ }
132
+
133
+ export interface BowlingCardRow {
134
+ innings_num: number;
135
+ order_num: number;
136
+ player: string;
137
+ balls: number;
138
+ runs_conceded: number;
139
+ wickets: number;
140
+ }
141
+ export async function getBowlingCards(db: D1Like, matchId: number): Promise<BowlingCardRow[]> {
142
+ const r = await db
143
+ .prepare(
144
+ `SELECT innings_num, order_num, player, balls, runs_conceded, wickets
145
+ FROM bowling_cards WHERE match_id = ? ORDER BY innings_num, order_num`,
146
+ )
147
+ .bind(matchId)
148
+ .all<BowlingCardRow>();
149
+ return r.results;
150
+ }
151
+
152
+ /** Resolve a free-text player query against the small `players` table
153
+ * (one row per distinct name string Cricsheet uses). Cricsheet names are
154
+ * short forms ("V Kohli", "SR Watson") -- matching is case-insensitive
155
+ * substring, ranked by how many matches that name appears in, so a common
156
+ * surname like "Kohli" resolves unambiguously while a genuinely ambiguous
157
+ * query (e.g. a shared surname) comes back as a candidate list rather than
158
+ * picking one silently. */
159
+ export interface PlayerCandidate {
160
+ player: string;
161
+ matches: number;
162
+ }
163
+ export async function resolvePlayer(db: D1Like, query: string): Promise<{ resolved: string | null; candidates: PlayerCandidate[] }> {
164
+ const exact = await db
165
+ .prepare(`SELECT player, matches FROM players WHERE player = ? COLLATE NOCASE`)
166
+ .bind(query)
167
+ .first<PlayerCandidate>();
168
+ if (exact) return { resolved: exact.player, candidates: [exact] };
169
+
170
+ const r = await db
171
+ .prepare(`SELECT player, matches FROM players WHERE player LIKE ? COLLATE NOCASE ORDER BY matches DESC LIMIT 20`)
172
+ .bind(`%${query}%`)
173
+ .all<PlayerCandidate>();
174
+ if (r.results.length === 1) return { resolved: r.results[0].player, candidates: r.results };
175
+ return { resolved: null, candidates: r.results };
176
+ }
177
+
178
+ export interface BattingAgg {
179
+ innings: number;
180
+ runs: number;
181
+ balls: number;
182
+ outs: number;
183
+ not_outs: number;
184
+ fours: number;
185
+ sixes: number;
186
+ high_score: number | null;
187
+ hundreds: number;
188
+ fifties: number;
189
+ average: number | null;
190
+ strike_rate: number | null;
191
+ }
192
+ export interface BowlingAgg {
193
+ innings: number;
194
+ balls: number;
195
+ runs_conceded: number;
196
+ wickets: number;
197
+ best_wickets: number | null;
198
+ average: number | null;
199
+ economy: number | null;
200
+ }
201
+
202
+ export async function battingAggregate(db: D1Like, player: string, format: Format | null): Promise<BattingAgg> {
203
+ const where = format ? 'player = ? AND format = ?' : 'player = ?';
204
+ const params = format ? [player, format] : [player];
205
+ const row = await db
206
+ .prepare(
207
+ `SELECT COUNT(*) AS innings, COALESCE(SUM(runs),0) AS runs, COALESCE(SUM(balls),0) AS balls,
208
+ COALESCE(SUM(CASE WHEN not_out = 0 THEN 1 ELSE 0 END),0) AS outs,
209
+ COALESCE(SUM(CASE WHEN not_out = 1 THEN 1 ELSE 0 END),0) AS not_outs,
210
+ COALESCE(SUM(fours),0) AS fours, COALESCE(SUM(sixes),0) AS sixes,
211
+ MAX(runs) AS high_score,
212
+ COALESCE(SUM(CASE WHEN runs >= 100 THEN 1 ELSE 0 END),0) AS hundreds,
213
+ COALESCE(SUM(CASE WHEN runs >= 50 AND runs < 100 THEN 1 ELSE 0 END),0) AS fifties
214
+ FROM batting_cards WHERE ${where}`,
215
+ )
216
+ .bind(...params)
217
+ .first<{ innings: number; runs: number; balls: number; outs: number; not_outs: number; fours: number; sixes: number; high_score: number | null; hundreds: number; fifties: number }>();
218
+ const innings = row?.innings ?? 0;
219
+ const runs = row?.runs ?? 0;
220
+ const balls = row?.balls ?? 0;
221
+ const outs = row?.outs ?? 0;
222
+ return {
223
+ innings,
224
+ runs,
225
+ balls,
226
+ outs,
227
+ not_outs: row?.not_outs ?? 0,
228
+ fours: row?.fours ?? 0,
229
+ sixes: row?.sixes ?? 0,
230
+ high_score: row?.high_score ?? null,
231
+ hundreds: row?.hundreds ?? 0,
232
+ fifties: row?.fifties ?? 0,
233
+ average: outs > 0 ? round2(runs / outs) : null,
234
+ strike_rate: balls > 0 ? round2((runs / balls) * 100) : null,
235
+ };
236
+ }
237
+
238
+ export async function bowlingAggregate(db: D1Like, player: string, format: Format | null): Promise<BowlingAgg> {
239
+ const where = format ? 'player = ? AND format = ?' : 'player = ?';
240
+ const params = format ? [player, format] : [player];
241
+ const row = await db
242
+ .prepare(
243
+ `SELECT COUNT(*) AS innings, COALESCE(SUM(balls),0) AS balls, COALESCE(SUM(runs_conceded),0) AS runs_conceded,
244
+ COALESCE(SUM(wickets),0) AS wickets, MAX(wickets) AS best_wickets
245
+ FROM bowling_cards WHERE ${where}`,
246
+ )
247
+ .bind(...params)
248
+ .first<{ innings: number; balls: number; runs_conceded: number; wickets: number; best_wickets: number | null }>();
249
+ const innings = row?.innings ?? 0;
250
+ const balls = row?.balls ?? 0;
251
+ const runs_conceded = row?.runs_conceded ?? 0;
252
+ const wickets = row?.wickets ?? 0;
253
+ return {
254
+ innings,
255
+ balls,
256
+ runs_conceded,
257
+ wickets,
258
+ best_wickets: row?.best_wickets ?? null,
259
+ average: wickets > 0 ? round2(runs_conceded / wickets) : null,
260
+ economy: balls > 0 ? round2(runs_conceded / (balls / 6)) : null,
261
+ };
262
+ }
263
+
264
+ function round2(n: number): number {
265
+ return Math.round(n * 100) / 100;
266
+ }
267
+
268
+ export interface HeadToHeadFilter {
269
+ team_a: string;
270
+ team_b: string;
271
+ format: Format | null;
272
+ limit: number;
273
+ offset: number;
274
+ }
275
+ export async function headToHeadMatches(db: D1Like, f: HeadToHeadFilter): Promise<MatchRow[]> {
276
+ const where = ['((team1 = ? COLLATE NOCASE AND team2 = ? COLLATE NOCASE) OR (team1 = ? COLLATE NOCASE AND team2 = ? COLLATE NOCASE))'];
277
+ const params: unknown[] = [f.team_a, f.team_b, f.team_b, f.team_a];
278
+ if (f.format) {
279
+ where.push('format = ?');
280
+ params.push(f.format);
281
+ }
282
+ const sql = `SELECT match_id, format, gender, team1, team2, match_date, season, venue, city, event_name,
283
+ toss_winner, toss_decision, outcome_winner, outcome_by_runs, outcome_by_wickets,
284
+ outcome_by_innings, outcome_method, result, player_of_match
285
+ FROM matches WHERE ${where.join(' AND ')} ORDER BY match_date DESC LIMIT ? OFFSET ?`;
286
+ params.push(f.limit, f.offset);
287
+ const r = await db.prepare(sql).bind(...params).all<MatchRow>();
288
+ return r.results;
289
+ }
290
+ export async function headToHeadCount(db: D1Like, f: Omit<HeadToHeadFilter, 'limit' | 'offset'>): Promise<number> {
291
+ const where = ['((team1 = ? COLLATE NOCASE AND team2 = ? COLLATE NOCASE) OR (team1 = ? COLLATE NOCASE AND team2 = ? COLLATE NOCASE))'];
292
+ const params: unknown[] = [f.team_a, f.team_b, f.team_b, f.team_a];
293
+ if (f.format) {
294
+ where.push('format = ?');
295
+ params.push(f.format);
296
+ }
297
+ const r = await db.prepare(`SELECT COUNT(*) AS n FROM matches WHERE ${where.join(' AND ')}`).bind(...params).first<{ n: number }>();
298
+ return r?.n ?? 0;
299
+ }
300
+
301
+ export interface FindMatchesFilter {
302
+ team?: string;
303
+ format?: Format | null;
304
+ season?: string;
305
+ venue?: string;
306
+ player?: string;
307
+ limit: number;
308
+ offset: number;
309
+ }
310
+ export async function findMatches(db: D1Like, f: FindMatchesFilter): Promise<MatchRow[]> {
311
+ const where: string[] = [];
312
+ const params: unknown[] = [];
313
+ if (f.team) {
314
+ where.push('(team1 = ? COLLATE NOCASE OR team2 = ? COLLATE NOCASE)');
315
+ params.push(f.team, f.team);
316
+ }
317
+ if (f.format) {
318
+ where.push('format = ?');
319
+ params.push(f.format);
320
+ }
321
+ if (f.season) {
322
+ where.push('season = ? COLLATE NOCASE');
323
+ params.push(f.season);
324
+ }
325
+ if (f.venue) {
326
+ where.push('venue LIKE ? COLLATE NOCASE');
327
+ params.push(`%${f.venue}%`);
328
+ }
329
+ if (f.player) {
330
+ where.push(
331
+ 'match_id IN (SELECT match_id FROM batting_cards WHERE player = ? COLLATE NOCASE UNION SELECT match_id FROM bowling_cards WHERE player = ? COLLATE NOCASE)',
332
+ );
333
+ params.push(f.player, f.player);
334
+ }
335
+ const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
336
+ const sql = `SELECT match_id, format, gender, team1, team2, match_date, season, venue, city, event_name,
337
+ toss_winner, toss_decision, outcome_winner, outcome_by_runs, outcome_by_wickets,
338
+ outcome_by_innings, outcome_method, result, player_of_match
339
+ FROM matches ${whereSql} ORDER BY match_date DESC LIMIT ? OFFSET ?`;
340
+ params.push(f.limit, f.offset);
341
+ const r = await db.prepare(sql).bind(...params).all<MatchRow>();
342
+ return r.results;
343
+ }
344
+ export async function findMatchesCount(db: D1Like, f: Omit<FindMatchesFilter, 'limit' | 'offset'>): Promise<number> {
345
+ const where: string[] = [];
346
+ const params: unknown[] = [];
347
+ if (f.team) {
348
+ where.push('(team1 = ? COLLATE NOCASE OR team2 = ? COLLATE NOCASE)');
349
+ params.push(f.team, f.team);
350
+ }
351
+ if (f.format) {
352
+ where.push('format = ?');
353
+ params.push(f.format);
354
+ }
355
+ if (f.season) {
356
+ where.push('season = ? COLLATE NOCASE');
357
+ params.push(f.season);
358
+ }
359
+ if (f.venue) {
360
+ where.push('venue LIKE ? COLLATE NOCASE');
361
+ params.push(`%${f.venue}%`);
362
+ }
363
+ if (f.player) {
364
+ where.push(
365
+ 'match_id IN (SELECT match_id FROM batting_cards WHERE player = ? COLLATE NOCASE UNION SELECT match_id FROM bowling_cards WHERE player = ? COLLATE NOCASE)',
366
+ );
367
+ params.push(f.player, f.player);
368
+ }
369
+ const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
370
+ const r = await db.prepare(`SELECT COUNT(*) AS n FROM matches ${whereSql}`).bind(...params).first<{ n: number }>();
371
+ return r?.n ?? 0;
372
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022"],
7
+ "types": ["@cloudflare/workers-types"],
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "resolveJsonModule": true,
12
+ "outDir": "dist",
13
+ "rootDir": "src",
14
+ "declaration": true
15
+ },
16
+ "include": ["src"],
17
+ "exclude": ["src/server.ts"]
18
+ }