import { Kysely } from 'kysely'; import { D1Dialect } from 'kysely-d1'; /** The subset of the platform env this module needs. */ type PlatformEnv = NonNullable; /** * Minimal structural shape needed to reach the D1 binding. `App.Platform` * satisfies it, as does the plain `{ env }` of a standalone Worker. */ export interface DbEnv { env?: Pick; } export interface GameRecord { id: string; // AT URI (game_at_uri) rkey: string; // Record key (TID) creator_did: string; // DID of whoever created the game (owns the ATProto record) player_one: string | null; player_two: string | null; board_size: number; status: 'waiting' | 'active' | 'completed'; action_count: number; last_action_type: string | null; winner: string | null; handicap: number; created_at: string; updated_at: string; } export interface Database { games: GameRecord; } export function getDb(platform: DbEnv | undefined): Kysely { // Production: Use Cloudflare D1 if (platform?.env?.DB) { return new Kysely({ dialect: new D1Dialect({ database: platform.env.DB }), }); } // No D1 configured throw new Error( 'Database not configured. In Cloudflare, set up D1 and bind it as "DB". ' + 'For local development, use `wrangler pages dev` with D1 bindings.' ); } const UFOS_API = 'https://ufos-api.microcosm.blue'; const CONSTELLATION_API = 'https://constellation.microcosm.blue/xrpc'; /** * Read the backlink count out of a Constellation `getBacklinks` response. * `records` is truncated to `limit`, so prefer the authoritative `total`. */ function backlinkCount(data: unknown): number { const d = data as { total?: unknown; records?: unknown } | null; if (typeof d?.total === 'number') return d.total; return Array.isArray(d?.records) ? d.records.length : 0; } /** * Look up a game by rkey, falling back to UFOs + Constellation for * records created outside Cloud Go (third-party PDS writes). * Upserts into D1 so subsequent lookups are fast. */ export async function ensureGameInDb( rkey: string, platform: DbEnv | undefined ): Promise<[GameRecord, Kysely] | null> { const db = getDb(platform); const existing = await db .selectFrom('games') .selectAll() .where('rkey', '=', rkey) .executeTakeFirst(); if (existing) return [existing, db]; // Not in the discovery index — fall back to the public ATProto indexes. // NOTE: UFOs `/records` ignores `limit` and returns only a capped sample of // recent records (no cursor), so this is best-effort: older games are not // discoverable here and will 404. const ufosRes = await fetch(`${UFOS_API}/records?collection=boo.sky.go.game`); if (!ufosRes.ok) return null; const allGames = (await ufosRes.json()) as Array<{ did: string; collection: string; rkey: string; record: Record; time_us: number; }>; if (!Array.isArray(allGames)) return null; const game = allGames.find((g) => g.rkey === rkey); if (!game) return null; const uri = `at://${game.did}/${game.collection}/${game.rkey}`; const rec = game.record as Record; const creatorDid = game.did; const playerOne = (rec.playerOne as string) || creatorDid; let playerTwo = (rec.playerTwo as string) || null; // Reconstruct action state from Constellation backlinks const [movesRes, passesRes, resignsRes] = await Promise.all([ fetch( `${CONSTELLATION_API}/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(uri)}&source=boo.sky.go.move:game&limit=1000` ), fetch( `${CONSTELLATION_API}/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(uri)}&source=boo.sky.go.pass:game&limit=1000` ), fetch( `${CONSTELLATION_API}/blue.microcosm.links.getBacklinks?subject=${encodeURIComponent(uri)}&source=boo.sky.go.resign:game&limit=1000` ), ]); const movesData = movesRes.ok ? await movesRes.json() : null; const passesData = passesRes.ok ? await passesRes.json() : null; const resignsData = resignsRes.ok ? await resignsRes.json() : null; const moveRecords: Array<{ did: string }> = Array.isArray(movesData?.records) ? movesData.records : []; const moveCount = backlinkCount(movesData); const passCount = backlinkCount(passesData); const resignCount = backlinkCount(resignsData); const actionCount = moveCount + passCount; // Infer playerTwo from backlink authors if not set in the record if (!playerTwo && actionCount >= 2) { const dids = new Set(); for (const m of moveRecords) dids.add(m.did); dids.delete(creatorDid); if (dids.size === 1) playerTwo = [...dids][0]; } // Derive status from PDS data let status: 'waiting' | 'active' | 'completed' = 'waiting'; if ( (rec.blackScore !== undefined && rec.blackScore !== null) || (rec.whiteScore !== undefined && rec.whiteScore !== null) || (rec.winner !== undefined && rec.winner !== null) || resignCount > 0 ) { status = 'completed'; } else if (actionCount > 0 || playerTwo) { status = 'active'; } // Determine last action type for turn/consecutive-pass tracking. // Without per-record timestamps we can't order mixed actions precisely; // conservatively treat the last action as 'move' whenever moves exist, // since two consecutive passes (game-ending) require *only* passes at the tail. let lastActionType: string | null = null; if (actionCount > 0) { lastActionType = passCount > 0 && moveCount === 0 ? 'pass' : 'move'; } const now = new Date().toISOString(); const createdAt = typeof rec.createdAt === 'string' ? rec.createdAt : now; const row: GameRecord = { id: uri, rkey, creator_did: creatorDid, player_one: playerOne, player_two: playerTwo, board_size: (rec.boardSize as number) || 19, status, action_count: actionCount, last_action_type: lastActionType, winner: (rec.winner as string) || null, handicap: (rec.handicap as number) || 0, created_at: createdAt, updated_at: now, }; await db .insertInto('games') .values(row) .onConflict((oc) => oc.column('id').doUpdateSet({ player_two: row.player_two, board_size: row.board_size, status: row.status, action_count: row.action_count, last_action_type: row.last_action_type, winner: row.winner, handicap: row.handicap, updated_at: now, }) ) .execute(); return [row, db]; }