Files
website/src/lib/server/db/schema/catch-em-all.ts
Stan Runge 64fb8df262 db: Drizzle schemas on domain Postgres schemas, baseline migration, reorg SQL
- pgSchema tables for auth, money, house, ref, games, cs, animaldex, site, meta
  (lists excluded via schemaFilter; runtime list tables are managed by meta.*)
- drizzle/ baseline matches the reorged DB exactly (catalog diff = 0 on rehearsal)
- db/reorg: one-time SQL that moves the flat public schema (rehearsed on a prod copy)
- scripts/db-migrate.ts: migrator with --init to record the baseline and a guard
  against running the baseline on a non-empty, unrecorded database
2026-10-23 22:03:42 +00:00

183 lines
6.2 KiB
TypeScript

import { boolean, index, integer, text, unique } from 'drizzle-orm/pg-core';
import { gamesSchema, refSchema } from './schemas';
import { availabilityValues } from '../../../availability';
import { user } from './auth';
import { id, timestamps } from './helpers';
export const availability = refSchema.enum('availability', availabilityValues);
// Where a catalog row came from. Seeding replaces `pokeapi` and `derived` rows
// but never touches `manual` ones, which are admin corrections.
export const catalogSources = ['pokeapi', 'derived', 'manual'] as const;
const source = () => text('source', { enum: catalogSources }).notNull().default('pokeapi');
export const pokemon = refSchema.table('pokemon', {
id: id(),
number: integer('number').notNull().unique(),
generation: integer('generation').notNull().default(1),
name: text('name').notNull(),
type1: text('type1').notNull(),
type2: text('type2'),
hp: integer('hp').notNull().default(0),
attack: integer('attack').notNull().default(0),
defense: integer('defense').notNull().default(0),
specialAttack: integer('special_attack').notNull().default(0),
specialDefense: integer('special_defense').notNull().default(0),
speed: integer('speed').notNull().default(0),
// national numbers, so the chain survives reseeding without id lookups
evolvesFrom: integer('evolves_from'),
evolutionChain: integer('evolution_chain'),
isBaby: boolean('is_baby').notNull().default(false),
isLegendary: boolean('is_legendary').notNull().default(false),
isMythical: boolean('is_mythical').notNull().default(false),
...timestamps
});
export const games = refSchema.table('pokemon_games', {
id: id(),
sourceId: integer('source_id').unique(),
slug: text('slug').unique(),
name: text('name').notNull(),
generation: integer('generation').notNull().default(1),
sortOrder: integer('sort_order').notNull().default(0),
breeding: boolean('breeding').notNull().default(true),
...timestamps
});
export const pokemonGameEntries = refSchema.table(
'pokemon_game_entries',
{
id: id(),
pokemonId: integer('pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
availability: availability('availability').notNull().default('catchable'),
// how to obtain it, e.g. "Evolve Wartortle: Level 36+" or "Route 22, Viridian Forest"
note: text('note'),
source: source(),
...timestamps
},
(table) => [unique().on(table.pokemonId, table.gameId), index().on(table.gameId)]
);
export const pokemonLocations = refSchema.table('pokemon_locations', {
id: id(),
sourceId: integer('source_id').unique(),
slug: text('slug').notNull(),
name: text('name').notNull(),
region: text('region'),
description: text('description'),
// R2 object key of an admin-uploaded picture
imageKey: text('image_key'),
imageCredit: text('image_credit'),
source: source(),
...timestamps
});
export const pokemonEncounters = refSchema.table(
'pokemon_encounters',
{
id: id(),
pokemonId: integer('pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
locationId: integer('location_id')
.notNull()
.references(() => pokemonLocations.id, { onDelete: 'cascade' }),
area: text('area'),
method: text('method').notNull(),
minLevel: integer('min_level'),
maxLevel: integer('max_level'),
// summed encounter-slot percentage; null for gifts and folded raids
rarity: integer('rarity'),
form: text('form'),
conditions: text('conditions').array().notNull().default([]),
note: text('note'),
source: source(),
...timestamps
},
(table) => [index().on(table.pokemonId, table.gameId), index().on(table.locationId)]
);
// The method that applies in each game, e.g. Magneton → Magnezone needs Mt.
// Coronet in Platinum but a Thunder Stone in Sword.
export const pokemonEvolutions = refSchema.table(
'pokemon_evolutions',
{
id: id(),
fromPokemonId: integer('from_pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
toPokemonId: integer('to_pokemon_id')
.notNull()
.references(() => pokemon.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
method: text('method').notNull(),
source: source(),
...timestamps
},
(table) => [
unique().on(table.fromPokemonId, table.toPokemonId, table.gameId, table.method),
index().on(table.gameId)
]
);
export const userEntries = gamesSchema.table(
'catch_em_all_entries',
{
id: id(),
pokemonGameEntryId: integer('pokemon_game_entry_id')
.notNull()
.references(() => pokemonGameEntries.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
...timestamps
},
(table) => [unique().on(table.pokemonGameEntryId, table.userId), index().on(table.userId)]
);
// which games a user has chosen to track ("rules")
export const userGames = gamesSchema.table(
'catch_em_all_games',
{
id: id(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
gameId: integer('game_id')
.notNull()
.references(() => games.id, { onDelete: 'cascade' }),
...timestamps
},
(table) => [unique().on(table.userId, table.gameId), index().on(table.userId)]
);
// A row distinguishes an explicit empty game selection from a new account.
export const catchEmAllSettings = gamesSchema.table('catch_em_all_settings', {
id: id(),
userId: text('user_id')
.notNull()
.unique()
.references(() => user.id, { onDelete: 'cascade' }),
...timestamps
});
export type Pokemon = typeof pokemon.$inferSelect;
export type Game = typeof games.$inferSelect;
export type PokemonGameEntry = typeof pokemonGameEntries.$inferSelect;
export type UserEntry = typeof userEntries.$inferSelect;
export type UserGame = typeof userGames.$inferSelect;
export type Availability = (typeof availability.enumValues)[number];
export type PokemonLocation = typeof pokemonLocations.$inferSelect;
export type PokemonEncounter = typeof pokemonEncounters.$inferSelect;
export type PokemonEvolution = typeof pokemonEvolutions.$inferSelect;