import { readFile } from 'node:fs/promises'; import { drizzle } from 'drizzle-orm/postgres-js'; import { and, eq, isNull, ne, sql } from 'drizzle-orm'; import postgres from 'postgres'; import { pokemon, games, pokemonGameEntries, pokemonLocations, pokemonEncounters, pokemonEvolutions } from '../src/lib/server/db/schema/catch-em-all'; type Availability = 'catchable' | 'unknown' | 'evolve' | 'breed' | 'event_only'; type Catalog = { source: { revision: string }; pokemon: (typeof pokemon.$inferInsert)[]; games: { sourceId: number; name: string; slug: string; generation: number; sortOrder: number; breeding: boolean; }[]; entries: [number, number, Availability, string?][]; evolutions: [from: number, to: number, versions: number[], method: string][]; }; type Locations = { locations: { sourceId: number; slug: string; name: string; region: string | null }[]; methods: string[]; conditions: string[]; encounters: [ number: number, version: number, location: number, area: string | null, method: number, minLevel: number, maxLevel: number, rarity: number | null, form: string | null, conditions: number[] ][]; }; if (!process.env.DB_URL) throw new Error('DB_URL is required'); const read = async (name: string): Promise => JSON.parse(await readFile(new URL(`../data/${name}`, import.meta.url), 'utf8')); const catalog = await read('pokemon-catalog.json'); const places = await read('pokemon-locations.json'); const client = postgres(process.env.DB_URL, { max: 1 }); const db = drizzle({ client }); async function inBatches(rows: T[], insert: (batch: T[]) => Promise) { for (let offset = 0; offset < rows.length; offset += 1000) await insert(rows.slice(offset, offset + 1000)); } try { await db.transaction(async (tx) => { const pokemonIds = new Map(); const gameIds = new Map(); const locationIds = new Map(); for (const row of catalog.pokemon) { const [saved] = await tx .insert(pokemon) .values(row) .onConflictDoUpdate({ target: pokemon.number, set: row }) .returning(); pokemonIds.set(saved.number, saved.id); } for (const row of catalog.games) { // Adopt older rows by exact name to preserve existing progress and game IDs. const [imported] = await tx.select().from(games).where(eq(games.sourceId, row.sourceId)); if (!imported) { const legacy = await tx .select() .from(games) .where(and(eq(games.name, row.name), isNull(games.sourceId))); if (legacy.length > 1) throw new Error(`Ambiguous legacy game: ${row.name}`); if (legacy[0]) await tx.update(games).set({ sourceId: row.sourceId }).where(eq(games.id, legacy[0].id)); } const [saved] = await tx .insert(games) .values(row) .onConflictDoUpdate({ target: games.sourceId, set: row }) .returning(); gameIds.set(row.sourceId, saved.id); } const lookup = (map: Map, key: number, kind: string) => { const id = map.get(key); if (!id) throw new Error(`Unknown catalog ${kind} ${key}`); return id; }; await inBatches(catalog.entries, (batch) => tx .insert(pokemonGameEntries) .values( batch.map(([number, version, availability, note]) => ({ pokemonId: lookup(pokemonIds, number, 'Pokémon'), gameId: lookup(gameIds, version, 'game'), availability, note: note ?? null, source: availability === 'catchable' || availability === 'unknown' ? ('pokeapi' as const) : ('derived' as const) })) ) .onConflictDoUpdate({ target: [pokemonGameEntries.pokemonId, pokemonGameEntries.gameId], set: { availability: sql`excluded.availability`, note: sql`excluded.note`, source: sql`excluded.source` }, // Admin corrections are never overwritten. setWhere: ne(pokemonGameEntries.source, 'manual') }) ); for (const row of places.locations) { const [saved] = await tx .insert(pokemonLocations) .values(row) .onConflictDoUpdate({ target: pokemonLocations.sourceId, // descriptions and pictures are site-owned and kept set: { slug: row.slug, name: row.name, region: row.region }, setWhere: ne(pokemonLocations.source, 'manual') }) .returning(); locationIds.set(row.sourceId, saved?.id ?? 0); } for (const [sourceId, id] of locationIds) { if (id) continue; const [manual] = await tx .select({ id: pokemonLocations.id }) .from(pokemonLocations) .where(eq(pokemonLocations.sourceId, sourceId)); locationIds.set(sourceId, manual.id); } // Encounters and evolutions have no natural key; replace imported rows wholesale. await tx.delete(pokemonEncounters).where(ne(pokemonEncounters.source, 'manual')); await inBatches(places.encounters, (batch) => tx.insert(pokemonEncounters).values( batch.map( ([ number, version, location, area, method, minLevel, maxLevel, rarity, form, conditions ]) => ({ pokemonId: lookup(pokemonIds, number, 'Pokémon'), gameId: lookup(gameIds, version, 'game'), locationId: lookup(locationIds, location, 'location'), area, method: places.methods[method], minLevel, maxLevel, rarity, form, conditions: conditions.map((index) => places.conditions[index]) }) ) ) ); await tx.delete(pokemonEvolutions).where(ne(pokemonEvolutions.source, 'manual')); const evolutions = catalog.evolutions.flatMap(([from, to, versions, method]) => versions.map((version) => ({ fromPokemonId: lookup(pokemonIds, from, 'Pokémon'), toPokemonId: lookup(pokemonIds, to, 'Pokémon'), gameId: lookup(gameIds, version, 'game'), method })) ); await inBatches(evolutions, (batch) => tx.insert(pokemonEvolutions).values(batch).onConflictDoNothing() ); console.log( `Seeded ${catalog.pokemon.length} species, ${catalog.games.length} versions, ${catalog.entries.length} entries, ` + `${places.locations.length} locations, ${places.encounters.length} encounters and ` + `${evolutions.length} evolution methods from ${catalog.source.revision}` ); }); } finally { await client.end(); }