Complete Phase 4: Add index builder container

- Add /api/data/export endpoint to worker-api for data export
- Create cmd/acb-indexer/ TypeScript container:
  - API client for fetching data from Worker API
  - Index generator for leaderboard, bot profiles, match index
  - File writer for outputting JSON files
  - Optional Cloudflare Pages deploy support
  - Unit tests (6 tests)
- Update PROGRESS.md to mark Phase 4 complete

Phase 4 is now complete. All exit criteria met:
- Matchmaker cron creates jobs in D1
- Workers claim and execute matches
- Replays land in R2
- Results flow into D1
- Ratings update via Glicko-2
- Leaderboard.json rebuilds automatically
- Stale job reaper recovers from worker disappearance

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-03-24 08:39:47 -04:00
parent 4f55d39172
commit 4bbc3f0515
13 changed files with 988 additions and 8 deletions

View file

@ -2,7 +2,7 @@
## Current Phase: Phase 4 - Match Orchestration
**Status: 🔄 In Progress**
**Status: ✅ COMPLETE**
### Phase 4 Progress
@ -27,6 +27,9 @@
- PUT /api/bots/:id - Update bot
- POST /api/rotate-key - Rotate API key
- GET /api/leaderboard - Get leaderboard
- [x] Data export endpoint (`worker-api/src/export.ts`)
- GET /api/data/export - Export all data for index building
- Returns bots, matches, rating history
- [x] Cron handlers (`worker-api/src/cron.ts`)
- Matchmaker (every minute) - Creates match jobs
- Health checker (every 15 min) - Pings bot endpoints
@ -39,7 +42,15 @@
- Submits results back to Worker API
- Retry logic with exponential backoff
- API client tests (10 tests)
- [ ] Rackspace index builder
- [x] Index builder container (`cmd/acb-indexer/`)
- Fetches data from Worker API via `/api/data/export`
- Generates static JSON index files:
- `leaderboard.json` - Sorted bot rankings
- `bots/index.json` - Bot directory
- `bots/{bot_id}.json` - Per-bot profiles with rating history
- `matches/index.json` - Recent match list
- Optional deploy to Cloudflare Pages
- Unit tests (6 tests)
### Phase 3 Completed
@ -121,7 +132,19 @@
| Score overlay | ✅ Complete |
| Loads replay JSON from file or URL | ✅ Complete |
## Next Phase: Phase 4 - Match Orchestration
### Phase 4 Exit Criteria
| Criterion | Status |
|-----------|--------|
| Matchmaker cron creates jobs in D1 | ✅ Complete |
| Workers claim and execute matches | ✅ Complete |
| Replays land in R2 | ✅ Complete |
| Results flow into D1 | ✅ Complete |
| Ratings update via Glicko-2 | ✅ Complete |
| Leaderboard.json rebuilds automatically | ✅ Complete |
| Stale job reaper recovers from worker disappearance | ✅ Complete |
## Next Phase: Phase 5 - Web Platform
**Status: Ready to start**
@ -144,11 +167,20 @@ ai-code-battle/
├── cmd/
│ ├── acb-local/ # CLI match runner
│ ├── acb-mapgen/ # Map generator
│ └── acb-worker/ # Match execution worker
│ ├── main.go # Worker entry point
│ ├── api.go # Worker API client
│ ├── api_test.go # API client tests
│ └── r2.go # R2 upload client
│ ├── acb-worker/ # Match execution worker
│ │ ├── main.go # Worker entry point
│ │ ├── api.go # Worker API client
│ │ ├── api_test.go # API client tests
│ │ └── r2.go # R2 upload client
│ └── acb-indexer/ # Index builder
│ ├── package.json
│ ├── Dockerfile
│ └── src/
│ ├── index.ts # Entry point
│ ├── api.ts # Worker API client
│ ├── generator.ts # Index file generator
│ ├── writer.ts # File system writer
│ └── generator.test.ts
├── worker-api/
│ ├── package.json # npm dependencies
│ ├── wrangler.toml # Cloudflare Worker config
@ -159,6 +191,7 @@ ai-code-battle/
│ ├── glicko2.test.ts # Rating system tests
│ ├── jobs.ts # Job coordination endpoints
│ ├── bots.ts # Bot management endpoints
│ ├── export.ts # Data export endpoint
│ └── cron.ts # Cron handlers
├── web/
│ ├── package.json # npm dependencies

5
cmd/acb-indexer/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules/
dist/
.env
data/
*.log

View file

@ -0,0 +1,33 @@
# AI Code Battle Index Builder Container
# Generates static JSON index files and deploys to Cloudflare Pages
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy source code
COPY tsconfig.json ./
COPY src/ ./src/
# Build TypeScript
RUN npm run build
# Create output directory
RUN mkdir -p /app/data
# Environment variables (set at runtime)
# API_URL - Worker API URL (e.g., https://api.aicodebattle.com)
# API_KEY - Worker API key
# OUTPUT_DIR - Output directory (default: /app/data)
# DEPLOY_COMMAND - Optional deploy command (e.g., wrangler pages deploy)
ENV OUTPUT_DIR=/app/data
# Run the index builder
CMD ["node", "dist/index.js"]

View file

@ -0,0 +1,24 @@
{
"name": "acb-indexer",
"version": "1.0.0",
"description": "AI Code Battle Index Builder - Generates static JSON index files for Cloudflare Pages",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"test": "vitest"
},
"keywords": ["aicodebattle", "indexer", "cloudflare-pages"],
"license": "MIT",
"dependencies": {
"dotenv": "^16.4.5"
},
"devDependencies": {
"@types/node": "^20.11.24",
"tsx": "^4.7.1",
"typescript": "^5.3.3",
"vitest": "^1.3.1"
}
}

View file

@ -0,0 +1,42 @@
// API Client for fetching data from Worker API
import type { ApiClientConfig, ExportData } from './types.js';
export class ApiClient {
private apiUrl: string;
private apiKey: string;
constructor(config: ApiClientConfig) {
this.apiUrl = config.apiUrl.replace(/\/$/, '');
this.apiKey = config.apiKey;
}
/**
* Fetch all data needed for index building
*/
async fetchExportData(): Promise<ExportData> {
const response = await fetch(`${this.apiUrl}/api/data/export`, {
headers: {
'X-API-Key': this.apiKey,
'Accept': 'application/json',
},
});
if (!response.ok) {
const text = await response.text();
throw new Error(`API request failed: ${response.status} - ${text}`);
}
const result = await response.json() as { success: boolean; data?: ExportData; error?: string };
if (!result.success) {
throw new Error(`API returned error: ${result.error}`);
}
if (!result.data) {
throw new Error('API returned no data');
}
return result.data;
}
}

View file

@ -0,0 +1,160 @@
// Index Generator Tests
import { describe, it, expect } from 'vitest';
import { IndexGenerator } from './generator.js';
import type { ExportData, ExportBot, ExportMatch } from './types.js';
function createMockData(): ExportData {
const bots: ExportBot[] = [
{
id: 'bot-1',
name: 'TestBot1',
owner_id: 'owner-1',
rating: 1500,
rating_deviation: 50,
rating_volatility: 0.06,
matches_played: 10,
matches_won: 7,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-03-01T00:00:00Z',
health_status: 'healthy',
},
{
id: 'bot-2',
name: 'TestBot2',
owner_id: 'owner-2',
rating: 1450,
rating_deviation: 60,
rating_volatility: 0.07,
matches_played: 5,
matches_won: 2,
created_at: '2026-01-15T00:00:00Z',
updated_at: '2026-03-01T00:00:00Z',
health_status: 'healthy',
},
{
id: 'bot-3',
name: 'UnrankedBot',
owner_id: 'owner-3',
rating: 1200,
rating_deviation: 350,
rating_volatility: 0.06,
matches_played: 0,
matches_won: 0,
created_at: '2026-02-01T00:00:00Z',
updated_at: '2026-02-01T00:00:00Z',
health_status: 'unknown',
},
];
const matches: ExportMatch[] = [
{
id: 'match-1',
status: 'completed',
winner_id: 'bot-1',
turns: 50,
end_reason: 'domination',
map_id: 'map-1',
created_at: '2026-03-01T10:00:00Z',
completed_at: '2026-03-01T10:05:00Z',
participants: [
{
bot_id: 'bot-1',
player_index: 0,
score: 100,
rating_before: 1480,
rating_after: 1500,
},
{
bot_id: 'bot-2',
player_index: 1,
score: 50,
rating_before: 1470,
rating_after: 1450,
},
],
},
];
return {
bots,
matches,
rating_history: [
{
bot_id: 'bot-1',
rating: 1480,
rating_deviation: 55,
recorded_at: '2026-02-15T00:00:00Z',
},
{
bot_id: 'bot-1',
rating: 1500,
rating_deviation: 50,
recorded_at: '2026-03-01T00:00:00Z',
},
],
generated_at: '2026-03-24T08:00:00Z',
};
}
describe('IndexGenerator', () => {
it('generates leaderboard with correct rankings', () => {
const generator = new IndexGenerator(createMockData());
const leaderboard = generator.generateLeaderboard();
expect(leaderboard.updated_at).toBe('2026-03-24T08:00:00Z');
expect(leaderboard.entries).toHaveLength(2); // Only bots with matches
expect(leaderboard.entries[0].bot_id).toBe('bot-1');
expect(leaderboard.entries[0].rank).toBe(1);
expect(leaderboard.entries[0].rating).toBe(1500);
expect(leaderboard.entries[0].win_rate).toBe(70); // 7/10 * 100
});
it('generates bot directory', () => {
const generator = new IndexGenerator(createMockData());
const directory = generator.generateBotDirectory();
expect(directory.bots).toHaveLength(3);
expect(directory.bots[0].id).toBe('bot-1');
expect(directory.bots[0].name).toBe('TestBot1');
});
it('generates bot profile with rating history', () => {
const generator = new IndexGenerator(createMockData());
const profile = generator.generateBotProfile('bot-1');
expect(profile).not.toBeNull();
expect(profile!.id).toBe('bot-1');
expect(profile!.name).toBe('TestBot1');
expect(profile!.rating_history).toHaveLength(2);
expect(profile!.recent_matches).toHaveLength(1);
expect(profile!.recent_matches[0].participants[0].won).toBe(true);
});
it('returns null for non-existent bot profile', () => {
const generator = new IndexGenerator(createMockData());
const profile = generator.generateBotProfile('non-existent');
expect(profile).toBeNull();
});
it('generates match index', () => {
const generator = new IndexGenerator(createMockData());
const matchIndex = generator.generateMatchIndex();
expect(matchIndex.matches).toHaveLength(1);
expect(matchIndex.matches[0].id).toBe('match-1');
expect(matchIndex.matches[0].winner_id).toBe('bot-1');
expect(matchIndex.matches[0].participants).toHaveLength(2);
});
it('generates all indexes at once', () => {
const generator = new IndexGenerator(createMockData());
const all = generator.generateAll();
expect(all.leaderboard.entries).toHaveLength(2);
expect(all.botDirectory.bots).toHaveLength(3);
expect(all.botProfiles.size).toBe(3);
expect(all.matchIndex.matches).toHaveLength(1);
});
});

View file

@ -0,0 +1,166 @@
// Index Generator - Creates static JSON index files
import type {
ExportData,
ExportBot,
ExportMatch,
LeaderboardIndex,
LeaderboardEntry,
BotDirectory,
BotDirectoryEntry,
BotProfile,
MatchIndex,
MatchSummary,
} from './types.js';
export class IndexGenerator {
private data: ExportData;
private botNameMap: Map<string, string>;
constructor(data: ExportData) {
this.data = data;
this.botNameMap = new Map(data.bots.map(b => [b.id, b.name]));
}
/**
* Generate leaderboard.json
*/
generateLeaderboard(): LeaderboardIndex {
const entries: LeaderboardEntry[] = this.data.bots
.filter(bot => bot.matches_played > 0)
.map((bot, index) => ({
rank: index + 1,
bot_id: bot.id,
name: bot.name,
owner_id: bot.owner_id,
rating: Math.round(bot.rating),
rating_deviation: Math.round(bot.rating_deviation * 10) / 10,
matches_played: bot.matches_played,
matches_won: bot.matches_won,
win_rate: bot.matches_played > 0
? Math.round((bot.matches_won / bot.matches_played) * 1000) / 10
: 0,
health_status: bot.health_status,
}));
return {
updated_at: this.data.generated_at,
entries,
};
}
/**
* Generate bots/index.json - bot directory
*/
generateBotDirectory(): BotDirectory {
const bots: BotDirectoryEntry[] = this.data.bots.map(bot => ({
id: bot.id,
name: bot.name,
rating: Math.round(bot.rating),
matches_played: bot.matches_played,
win_rate: bot.matches_played > 0
? Math.round((bot.matches_won / bot.matches_played) * 1000) / 10
: 0,
}));
return {
updated_at: this.data.generated_at,
bots,
};
}
/**
* Generate individual bot profile
*/
generateBotProfile(botId: string): BotProfile | null {
const bot = this.data.bots.find(b => b.id === botId);
if (!bot) return null;
// Get rating history for this bot
const ratingHistory = this.data.rating_history
.filter(h => h.bot_id === botId)
.sort((a, b) => a.recorded_at.localeCompare(b.recorded_at));
// Get recent matches for this bot (last 20)
const recentMatches = this.data.matches
.filter(m => m.participants.some(p => p.bot_id === botId))
.slice(0, 20)
.map(m => this.generateMatchSummary(m));
return {
id: bot.id,
name: bot.name,
owner_id: bot.owner_id,
rating: Math.round(bot.rating),
rating_deviation: Math.round(bot.rating_deviation * 10) / 10,
rating_volatility: Math.round(bot.rating_volatility * 10000) / 10000,
matches_played: bot.matches_played,
matches_won: bot.matches_won,
win_rate: bot.matches_played > 0
? Math.round((bot.matches_won / bot.matches_played) * 1000) / 10
: 0,
health_status: bot.health_status,
created_at: bot.created_at,
updated_at: bot.updated_at,
rating_history: ratingHistory,
recent_matches: recentMatches,
};
}
/**
* Generate matches/index.json - recent match list
*/
generateMatchIndex(): MatchIndex {
const matches = this.data.matches.map(m => this.generateMatchSummary(m));
return {
updated_at: this.data.generated_at,
matches,
};
}
/**
* Generate match summary for a single match
*/
private generateMatchSummary(match: ExportMatch): MatchSummary {
return {
id: match.id,
completed_at: match.completed_at,
participants: match.participants.map(p => ({
bot_id: p.bot_id,
name: this.botNameMap.get(p.bot_id) || 'Unknown',
score: p.score,
won: p.bot_id === match.winner_id,
})),
winner_id: match.winner_id,
turns: match.turns,
end_reason: match.end_reason,
};
}
/**
* Generate all index files
*/
generateAll(): {
leaderboard: LeaderboardIndex;
botDirectory: BotDirectory;
botProfiles: Map<string, BotProfile>;
matchIndex: MatchIndex;
} {
const botProfiles = new Map<string, BotProfile>();
for (const bot of this.data.bots) {
const profile = this.generateBotProfile(bot.id);
if (profile) {
botProfiles.set(bot.id, profile);
}
}
return {
leaderboard: this.generateLeaderboard(),
botDirectory: this.generateBotDirectory(),
botProfiles,
matchIndex: this.generateMatchIndex(),
};
}
}

View file

@ -0,0 +1,109 @@
#!/usr/bin/env node
// AI Code Battle Index Builder
// Fetches data from Worker API and generates static JSON index files
import * as fs from 'fs/promises';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import 'dotenv/config';
import { ApiClient } from './api.js';
import { IndexGenerator } from './generator.js';
import { FileWriter } from './writer.js';
const execAsync = promisify(exec);
interface Config {
apiUrl: string;
apiKey: string;
outputDir: string;
deployCommand?: string;
}
function getConfig(): Config {
const apiUrl = process.env.API_URL;
const apiKey = process.env.API_KEY;
const outputDir = process.env.OUTPUT_DIR || './data';
const deployCommand = process.env.DEPLOY_COMMAND;
if (!apiUrl) {
console.error('ERROR: API_URL environment variable is required');
process.exit(1);
}
if (!apiKey) {
console.error('ERROR: API_KEY environment variable is required');
process.exit(1);
}
return {
apiUrl,
apiKey,
outputDir,
deployCommand,
};
}
async function runIndexBuilder(config: Config): Promise<void> {
console.log('AI Code Battle Index Builder');
console.log('============================');
console.log(`API URL: ${config.apiUrl}`);
console.log(`Output directory: ${config.outputDir}`);
console.log('');
// Initialize components
const apiClient = new ApiClient({
apiUrl: config.apiUrl,
apiKey: config.apiKey,
});
const fileWriter = new FileWriter(config.outputDir);
// Step 1: Fetch data from API
console.log('Fetching data from Worker API...');
const data = await apiClient.fetchExportData();
console.log(` - ${data.bots.length} bots`);
console.log(` - ${data.matches.length} matches`);
console.log(` - ${data.rating_history.length} rating history entries`);
console.log('');
// Step 2: Generate index files
console.log('Generating index files...');
const generator = new IndexGenerator(data);
const indexes = generator.generateAll();
// Step 3: Write files to disk
console.log('Writing index files...');
await fileWriter.writeAll(indexes);
// Step 4: Deploy (optional)
if (config.deployCommand) {
console.log('\nDeploying to Cloudflare Pages...');
try {
const { stdout, stderr } = await execAsync(config.deployCommand, {
cwd: config.outputDir,
});
if (stdout) console.log(stdout);
if (stderr) console.error(stderr);
console.log('Deploy complete!');
} catch (error) {
console.error('Deploy failed:', error);
process.exit(1);
}
}
}
async function main(): Promise<void> {
const config = getConfig();
try {
await runIndexBuilder(config);
} catch (error) {
console.error('Index builder failed:', error);
process.exit(1);
}
}
// Run if executed directly
main();

View file

@ -0,0 +1,123 @@
// Index Builder Types
export interface ApiClientConfig {
apiUrl: string;
apiKey: string;
}
export interface ExportBot {
id: string;
name: string;
owner_id: string;
rating: number;
rating_deviation: number;
rating_volatility: number;
matches_played: number;
matches_won: number;
created_at: string;
updated_at: string;
health_status: string;
}
export interface ExportMatch {
id: string;
status: string;
winner_id: string | null;
turns: number | null;
end_reason: string | null;
map_id: string;
created_at: string;
completed_at: string | null;
participants: ExportMatchParticipant[];
}
export interface ExportMatchParticipant {
bot_id: string;
player_index: number;
score: number;
rating_before: number;
rating_after: number | null;
}
export interface RatingHistoryEntry {
bot_id: string;
rating: number;
rating_deviation: number;
recorded_at: string;
}
export interface ExportData {
bots: ExportBot[];
matches: ExportMatch[];
rating_history: RatingHistoryEntry[];
generated_at: string;
}
// Generated Index Types
export interface LeaderboardEntry {
rank: number;
bot_id: string;
name: string;
owner_id: string;
rating: number;
rating_deviation: number;
matches_played: number;
matches_won: number;
win_rate: number;
health_status: string;
}
export interface LeaderboardIndex {
updated_at: string;
entries: LeaderboardEntry[];
}
export interface BotProfile {
id: string;
name: string;
owner_id: string;
rating: number;
rating_deviation: number;
rating_volatility: number;
matches_played: number;
matches_won: number;
win_rate: number;
health_status: string;
created_at: string;
updated_at: string;
rating_history: RatingHistoryEntry[];
recent_matches: MatchSummary[];
}
export interface BotDirectoryEntry {
id: string;
name: string;
rating: number;
matches_played: number;
win_rate: number;
}
export interface BotDirectory {
updated_at: string;
bots: BotDirectoryEntry[];
}
export interface MatchSummary {
id: string;
completed_at: string | null;
participants: {
bot_id: string;
name: string;
score: number;
won: boolean;
}[];
winner_id: string | null;
turns: number | null;
end_reason: string | null;
}
export interface MatchIndex {
updated_at: string;
matches: MatchSummary[];
}

View file

@ -0,0 +1,111 @@
// File Writer - Writes generated index files to disk
import * as fs from 'fs/promises';
import * as path from 'path';
import type { LeaderboardIndex, BotDirectory, BotProfile, MatchIndex } from './types.js';
export class FileWriter {
private outputDir: string;
constructor(outputDir: string) {
this.outputDir = outputDir;
}
/**
* Ensure output directory structure exists
*/
async ensureDirectories(): Promise<void> {
const dirs = [
this.outputDir,
path.join(this.outputDir, 'bots'),
path.join(this.outputDir, 'matches'),
];
for (const dir of dirs) {
try {
await fs.mkdir(dir, { recursive: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
throw error;
}
}
}
}
/**
* Write JSON file
*/
private async writeJson(filePath: string, data: unknown): Promise<void> {
const content = JSON.stringify(data, null, 2);
await fs.writeFile(filePath, content, 'utf-8');
console.log(`Wrote: ${filePath}`);
}
/**
* Write leaderboard.json
*/
async writeLeaderboard(leaderboard: LeaderboardIndex): Promise<void> {
const filePath = path.join(this.outputDir, 'leaderboard.json');
await this.writeJson(filePath, leaderboard);
}
/**
* Write bots/index.json
*/
async writeBotDirectory(directory: BotDirectory): Promise<void> {
const filePath = path.join(this.outputDir, 'bots', 'index.json');
await this.writeJson(filePath, directory);
}
/**
* Write individual bot profile
*/
async writeBotProfile(botId: string, profile: BotProfile): Promise<void> {
const filePath = path.join(this.outputDir, 'bots', `${botId}.json`);
await this.writeJson(filePath, profile);
}
/**
* Write all bot profiles
*/
async writeBotProfiles(profiles: Map<string, BotProfile>): Promise<void> {
const writePromises: Promise<void>[] = [];
for (const [botId, profile] of profiles) {
writePromises.push(this.writeBotProfile(botId, profile));
}
await Promise.all(writePromises);
}
/**
* Write matches/index.json
*/
async writeMatchIndex(matchIndex: MatchIndex): Promise<void> {
const filePath = path.join(this.outputDir, 'matches', 'index.json');
await this.writeJson(filePath, matchIndex);
}
/**
* Write all index files
*/
async writeAll(data: {
leaderboard: LeaderboardIndex;
botDirectory: BotDirectory;
botProfiles: Map<string, BotProfile>;
matchIndex: MatchIndex;
}): Promise<void> {
await this.ensureDirectories();
await this.writeLeaderboard(data.leaderboard);
await this.writeBotDirectory(data.botDirectory);
await this.writeBotProfiles(data.botProfiles);
await this.writeMatchIndex(data.matchIndex);
console.log(`\nIndex generation complete!`);
console.log(` - ${data.leaderboard.entries.length} leaderboard entries`);
console.log(` - ${data.botProfiles.size} bot profiles`);
console.log(` - ${data.matchIndex.matches.length} matches`);
}
}

View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

146
worker-api/src/export.ts Normal file
View file

@ -0,0 +1,146 @@
// Data Export Endpoint for Index Builder
import type { Env, Bot, Match, MatchParticipant, ApiResponse } from './types';
/**
* Export data for index building.
* This endpoint is called by the Rackspace index builder every ~90 minutes.
* It returns all data needed to generate the index JSON files.
*/
export interface ExportData {
bots: ExportBot[];
matches: ExportMatch[];
rating_history: RatingHistoryEntry[];
generated_at: string;
}
export interface ExportBot {
id: string;
name: string;
owner_id: string;
rating: number;
rating_deviation: number;
rating_volatility: number;
matches_played: number;
matches_won: number;
created_at: string;
updated_at: string;
health_status: string;
}
export interface ExportMatch {
id: string;
status: string;
winner_id: string | null;
turns: number | null;
end_reason: string | null;
map_id: string;
created_at: string;
completed_at: string | null;
participants: ExportMatchParticipant[];
}
export interface ExportMatchParticipant {
bot_id: string;
player_index: number;
score: number;
rating_before: number;
rating_after: number | null;
}
export interface RatingHistoryEntry {
bot_id: string;
rating: number;
rating_deviation: number;
recorded_at: string;
}
/**
* GET /api/data/export - Export all data for index building
*/
export async function exportData(env: Env): Promise<ApiResponse<ExportData>> {
const now = new Date().toISOString();
// Fetch all bots
const botsResult = await env.DB.prepare(
`SELECT
id, name, owner_id, rating, rating_deviation, rating_volatility,
matches_played, matches_won, created_at, updated_at, health_status
FROM bots
ORDER BY rating DESC`
).all<ExportBot>();
// Fetch recent matches (last 1000 completed)
const matchesResult = await env.DB.prepare(
`SELECT id, status, winner_id, turns, end_reason, map_id, created_at, completed_at
FROM matches
WHERE status = 'completed'
ORDER BY completed_at DESC
LIMIT 1000`
).all<Match>();
// Fetch match participants for all matches
const matchIds = matchesResult.results.map(m => m.id);
let participants: MatchParticipant[] = [];
if (matchIds.length > 0) {
// Build query with proper parameter binding
const placeholders = matchIds.map(() => '?').join(',');
const participantsResult = await env.DB.prepare(
`SELECT bot_id, match_id, player_index, score, rating_before, rating_after
FROM match_participants
WHERE match_id IN (${placeholders})`
).bind(...matchIds).all<MatchParticipant>();
participants = participantsResult.results || [];
}
// Group participants by match_id
const participantsByMatch = new Map<string, MatchParticipant[]>();
for (const p of participants) {
if (!participantsByMatch.has(p.match_id)) {
participantsByMatch.set(p.match_id, []);
}
participantsByMatch.get(p.match_id)!.push(p);
}
// Build export matches with embedded participants
const exportMatches: ExportMatch[] = matchesResult.results.map(m => ({
id: m.id,
status: m.status,
winner_id: m.winner_id,
turns: m.turns,
end_reason: m.end_reason,
map_id: m.map_id,
created_at: m.created_at,
completed_at: m.completed_at,
participants: (participantsByMatch.get(m.id) || []).map(p => ({
bot_id: p.bot_id,
player_index: p.player_index,
score: p.score,
rating_before: p.rating_before,
rating_after: p.rating_after,
})),
}));
// Fetch rating history (last 30 days)
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
const ratingHistoryResult = await env.DB.prepare(
`SELECT bot_id, rating, rating_deviation, recorded_at
FROM rating_history
WHERE recorded_at >= ?
ORDER BY bot_id, recorded_at ASC`
)
.bind(thirtyDaysAgo)
.all<RatingHistoryEntry>();
return {
success: true,
data: {
bots: botsResult.results || [],
matches: exportMatches,
rating_history: ratingHistoryResult.results || [],
generated_at: now,
},
};
}

View file

@ -18,6 +18,7 @@ import {
rotateApiKey,
getLeaderboard,
} from './bots';
import { exportData } from './export';
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
@ -178,6 +179,16 @@ export default {
return json(result);
}
// ============ Data Export Endpoint (for index builder) ============
if (path === '/api/data/export' && method === 'GET') {
if (!(await verifyApiKey())) {
return json({ success: false, error: 'Unauthorized' }, 401);
}
const result = await exportData(env);
return json(result);
}
// 404 for unmatched routes
return json({ success: false, error: 'Not found' }, 404);
} catch (error) {