beeeeg update
Some checks failed
Test and deploy staging / staging (push) Has been cancelled

This commit is contained in:
2026-09-23 04:15:10 +00:00
parent 487eff5bb2
commit d0c7b6548b
135 changed files with 9531 additions and 1777 deletions

View File

@@ -0,0 +1,84 @@
-- One-off: convert animaldex ids from uuid to serial, keeping existing rows.
-- Postgres can't cast uuid -> integer, so db:push can't do this itself.
--
-- Run BEFORE re-running db:push, once per database:
-- psql "$DB_URL" -v ON_ERROR_STOP=1 -f scripts/animaldex-uuid-to-serial.sql
--
-- Safe to run on an empty database too. Everything is in one transaction, so a
-- failure leaves the database untouched.
BEGIN;
-- the failed push created this before erroring out
DROP SEQUENCE IF EXISTS animals_id_seq;
-- 1. new integer ids alongside the uuid ones
ALTER TABLE animals ADD COLUMN new_id serial;
ALTER TABLE sightings ADD COLUMN new_id serial;
ALTER TABLE photos ADD COLUMN new_id serial;
-- 2. translate the foreign keys through the uuid -> integer mapping
ALTER TABLE sightings ADD COLUMN new_animal_id integer;
UPDATE sightings s SET new_animal_id = a.new_id FROM animals a WHERE a.id = s.animal_id;
ALTER TABLE photos ADD COLUMN new_sighting_id integer;
UPDATE photos p SET new_sighting_id = s.new_id FROM sightings s WHERE s.id = p.sighting_id;
-- 3. drop the old foreign keys (names vary by how they were created)
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT conrelid::regclass AS tbl, conname
FROM pg_constraint
WHERE contype = 'f' AND conrelid IN ('sightings'::regclass, 'photos'::regclass)
LOOP
EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', r.tbl, r.conname);
END LOOP;
END $$;
-- 4. swap the foreign key columns
ALTER TABLE photos DROP COLUMN sighting_id;
ALTER TABLE photos RENAME COLUMN new_sighting_id TO sighting_id;
ALTER TABLE photos ALTER COLUMN sighting_id SET NOT NULL;
ALTER TABLE sightings DROP COLUMN animal_id;
ALTER TABLE sightings RENAME COLUMN new_animal_id TO animal_id;
ALTER TABLE sightings ALTER COLUMN animal_id SET NOT NULL;
-- 5. swap the primary keys
DO $$
DECLARE r record;
BEGIN
FOR r IN
SELECT conrelid::regclass AS tbl, conname
FROM pg_constraint
WHERE contype = 'p'
AND conrelid IN ('animals'::regclass, 'sightings'::regclass, 'photos'::regclass)
LOOP
EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', r.tbl, r.conname);
END LOOP;
END $$;
ALTER TABLE animals DROP COLUMN id;
ALTER TABLE animals RENAME COLUMN new_id TO id;
ALTER TABLE animals ADD PRIMARY KEY (id);
ALTER SEQUENCE animals_new_id_seq RENAME TO animals_id_seq;
ALTER TABLE sightings DROP COLUMN id;
ALTER TABLE sightings RENAME COLUMN new_id TO id;
ALTER TABLE sightings ADD PRIMARY KEY (id);
ALTER SEQUENCE sightings_new_id_seq RENAME TO sightings_id_seq;
ALTER TABLE photos DROP COLUMN id;
ALTER TABLE photos RENAME COLUMN new_id TO id;
ALTER TABLE photos ADD PRIMARY KEY (id);
ALTER SEQUENCE photos_new_id_seq RENAME TO photos_id_seq;
-- 6. restore the foreign keys under the names drizzle expects
ALTER TABLE sightings ADD CONSTRAINT sightings_animal_id_animals_id_fkey
FOREIGN KEY (animal_id) REFERENCES animals(id) ON DELETE CASCADE;
ALTER TABLE photos ADD CONSTRAINT photos_sighting_id_sightings_id_fkey
FOREIGN KEY (sighting_id) REFERENCES sightings(id) ON DELETE CASCADE;
COMMIT;

View File

@@ -0,0 +1,18 @@
-- Additive migration for the existing domain schema. No users or progress are removed.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS role text NOT NULL DEFAULT 'user';
ALTER TABLE account ADD COLUMN IF NOT EXISTS issuer text;
-- Existing credentials use Better Auth's local namespace. Fail on unknown legacy
-- OAuth providers rather than silently changing which remote identity they trust.
UPDATE account SET issuer = 'local:credential' WHERE issuer IS NULL AND provider_id = 'credential';
UPDATE account SET issuer = 'https://accounts.google.com' WHERE issuer IS NULL AND provider_id = 'google';
UPDATE account SET issuer = 'local:oauth:github' WHERE issuer IS NULL AND provider_id = 'github';
UPDATE account SET issuer = 'local:oauth:osu' WHERE issuer IS NULL AND provider_id = 'osu';
ALTER TABLE account ALTER COLUMN issuer SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS account_issuer_account_id_unique ON account (issuer, account_id);
ALTER TABLE games ADD COLUMN IF NOT EXISTS source_id integer;
CREATE UNIQUE INDEX IF NOT EXISTS games_source_id_unique ON games (source_id);
CREATE UNIQUE INDEX IF NOT EXISTS pokemon_number_unique ON pokemon (number);
ALTER TYPE availability ADD VALUE IF NOT EXISTS 'unknown';
CREATE TABLE IF NOT EXISTS catch_em_all_settings (
user_id text PRIMARY KEY REFERENCES "user"(id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,96 @@
"""Build the species-level catalog from a pinned PokéAPI CSV snapshot.
Run with --source-dir to reuse downloaded CSVs. Default downloads into a temporary
folder. Encounter absence is not evidence of trade/transfer/event exclusivity.
"""
import argparse
import concurrent.futures
import csv
import json
from pathlib import Path
import tempfile
import urllib.request
REVISION = '575291cdb197a7e3a320297be276c9de4ef8401a'
TABLES = ['pokemon', 'pokemon_species', 'pokemon_species_names', 'pokemon_types',
'pokemon_stats', 'type_names', 'versions', 'version_names', 'version_groups',
'pokemon_dex_numbers', 'pokedex_version_groups', 'pokemon_game_indices', 'encounters']
def build(directory):
def rows(name):
with (directory / f'{name}.csv').open() as f:
return list(csv.DictReader(f))
species = rows('pokemon_species')
pokemon = rows('pokemon')
species_for = {int(p['id']): int(p['species_id']) for p in pokemon}
defaults = {int(p['species_id']): int(p['id']) for p in pokemon if p['is_default'] == '1'}
names = {int(p['pokemon_species_id']): p['name'] for p in rows('pokemon_species_names') if p['local_language_id'] == '9'}
type_names = {int(t['type_id']): t['name'] for t in rows('type_names') if t['local_language_id'] == '9'}
types = {}
for p in rows('pokemon_types'):
types.setdefault(int(p['pokemon_id']), {})[int(p['slot'])] = type_names[int(p['type_id'])]
stats = {}
for p in rows('pokemon_stats'):
stats.setdefault(int(p['pokemon_id']), {})[int(p['stat_id'])] = int(p['base_stat'])
stat_fields = ['hp', 'attack', 'defense', 'specialAttack', 'specialDefense', 'speed']
mons = []
for s in species:
number = int(s['id'])
default = defaults[number]
mons.append(dict(number=number, name=names[number], generation=int(s['generation_id']),
type1=types[default][1], type2=types[default].get(2),
**{name: stats[default][i + 1] for i, name in enumerate(stat_fields)}))
groups = {int(g['id']): g for g in rows('version_groups')}
versions = rows('versions')
game_names = {int(v['version_id']): v['name'] for v in rows('version_names') if v['local_language_id'] == '9'}
games = [dict(sourceId=int(v['id']), name=game_names.get(int(v['id']), v['identifier'].replace('-', ' ').title()),
generation=int(groups[int(v['version_group_id'])]['generation_id'])) for v in versions]
for game, version in zip(games, versions):
if version['identifier'].endswith('-japan'):
game['name'] += ' (Japan)'
versions_for_group = {}
for v in versions:
versions_for_group.setdefault(int(v['version_group_id']), []).append(int(v['id']))
groups_for_dex = {}
for d in rows('pokedex_version_groups'):
groups_for_dex.setdefault(int(d['pokedex_id']), []).append(int(d['version_group_id']))
entries = {}
# Membership and internal game indices establish presence, not how it is obtained.
for p in rows('pokemon_dex_numbers'):
for group in groups_for_dex.get(int(p['pokedex_id']), []):
for version in versions_for_group.get(group, []):
entries[(int(p['species_id']), version)] = 'unknown'
for p in rows('pokemon_game_indices'):
if int(p['game_index']) > 0:
entries[(species_for[int(p['pokemon_id'])], int(p['version_id']))] = 'unknown'
for p in rows('encounters'):
entries[(species_for[int(p['pokemon_id'])], int(p['version_id']))] = 'catchable'
return dict(source={'repository': 'https://github.com/PokeAPI/pokeapi', 'revision': REVISION,
'scope': 'All species and game versions in this snapshot; default-form types/stats. Unknown means presence without confirmed encounter data. Missing pairs have no source record, not proven absence.'},
pokemon=mons, games=games, entries=[[p, g, availability] for (p, g), availability in sorted(entries.items())])
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--source-dir', type=Path)
args = parser.parse_args()
with tempfile.TemporaryDirectory() as temporary:
directory = args.source_dir or Path(temporary)
if not args.source_dir:
def download(table):
url = f'https://raw.githubusercontent.com/PokeAPI/pokeapi/{REVISION}/data/v2/csv/{table}.csv'
with urllib.request.urlopen(url, timeout=60) as response:
(directory / f'{table}.csv').write_bytes(response.read())
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
list(executor.map(download, TABLES))
catalog = build(directory)
target = Path(__file__).resolve().parent.parent / 'data/pokemon-catalog.json'
target.write_text(json.dumps(catalog, ensure_ascii=False, separators=(',', ':')) + '\n')
print(f"Built {len(catalog['pokemon'])} species, {len(catalog['games'])} versions, {len(catalog['entries'])} entries")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,70 @@
import postgres from 'postgres';
const expectedOrigin = process.argv[2];
if (
!expectedOrigin ||
!['https://stanrunge.dev', 'https://staging.stanrunge.dev'].includes(expectedOrigin)
) {
throw new Error('Expected a known production or staging origin');
}
if (!process.env.DB_URL) throw new Error('DB_URL is missing');
if (!process.env.BETTER_AUTH_SECRET || process.env.BETTER_AUTH_SECRET.length < 32) {
throw new Error('BETTER_AUTH_SECRET must be at least 32 characters');
}
if (process.env.ORIGIN !== expectedOrigin || process.env.BETTER_AUTH_URL !== expectedOrigin) {
throw new Error('ORIGIN and BETTER_AUTH_URL must both match the public site origin');
}
const client = postgres(process.env.DB_URL, { max: 1, connect_timeout: 10 });
try {
await client`select 1`;
const [schema] = await client`
select
(select count(*) from information_schema.columns
where table_name = 'user' and column_name = 'role') as user_role,
(select count(*) from information_schema.columns
where table_name = 'account' and column_name = 'issuer') as account_issuer,
(select count(*) from information_schema.columns
where table_name = 'blog_articles' and column_name = 'tags') as blog_tags,
to_regclass('public.catch_em_all_settings') is not null as settings_ready,
(select count(*) from information_schema.columns where table_name = 'lists' and column_name = 'slug') as list_slug,
to_regclass('public.list_item_completions') is not null as list_completions,
(select count(*) from information_schema.columns where table_name = 'tasks_to_topics' and column_name = 'id') as topic_link_id,
(select count(*) from information_schema.columns
where table_name in ('animals', 'sightings', 'photos')
and column_name = 'id' and data_type = 'integer') as animal_serial_ids
`;
if (
!Number(schema.user_role) ||
!Number(schema.account_issuer) ||
!Number(schema.blog_tags) ||
!schema.settings_ready ||
!Number(schema.list_slug) ||
!schema.list_completions ||
!Number(schema.topic_link_id) ||
Number(schema.animal_serial_ids) !== 3
) {
throw new Error(
'Required migrations or Animaldex ID conversion have not been applied to this database'
);
}
const [catalog] = await client`
select
(select count(*) from pokemon) as pokemon,
(select count(*) from games where source_id is not null) as games,
(select count(*) from pokemon_game_entries) as entries
`;
if (
Number(catalog.pokemon) < 1025 ||
Number(catalog.games) < 53 ||
Number(catalog.entries) < 27267
) {
throw new Error('Pokémon catalog has not been seeded in this database');
}
} finally {
await client.end();
}
const google = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
const osu = !!(process.env.OSU_CLIENT_ID && process.env.OSU_CLIENT_SECRET);
console.log(
`Preflight passed for ${expectedOrigin}; Google ${google ? 'configured' : 'disabled'}, osu! ${osu ? 'configured' : 'disabled'}`
);

50
scripts/deploy-remote.sh Normal file
View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
sha=${1:?commit SHA required}
directory=${2:?deployment directory required}
origin=${3:?public origin required}
case "$sha" in
*[!0-9a-f]*|'') echo 'Invalid image tag' >&2; exit 2 ;;
esac
case "$directory:$origin" in
/opt/personal-website:https://stanrunge.dev)
environment=/etc/personal-website/prod.env ;;
/opt/personal-website-staging:https://staging.stanrunge.dev)
environment=/etc/personal-website-staging/staging.env ;;
*) echo 'Unrecognized deployment target' >&2; exit 2 ;;
esac
image="git.stanrunge.dev/stan/personal-website:$sha"
compose="$directory/compose.prod.yml"
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
switched=0
rollback() {
if [ "$switched" -eq 1 ] && [ -n "$previous_tag" ]; then
echo "Health check failed; restoring prior image tag $previous_tag" >&2
printf 'IMAGE_TAG=%s\n' "$previous_tag" | sudo tee "$directory/.env" >/dev/null
sudo docker compose --env-file "$directory/.env" -f "$compose" up -d
fi
}
trap rollback ERR
sudo docker run --rm --network backend --env-file "$environment" "$image" \
bun scripts/deploy-preflight.ts "$origin"
printf 'IMAGE_TAG=%s\n' "$sha" | sudo tee "$directory/.env" >/dev/null
switched=1
sudo docker compose --env-file "$directory/.env" -f "$compose" up -d
container=$(sudo docker compose --env-file "$directory/.env" -f "$compose" ps -q app)
expected_image=$(sudo docker image inspect -f '{{.Id}}' "$image")
actual_image=$(sudo docker inspect -f '{{.Image}}' "$container")
if [ "$actual_image" != "$expected_image" ]; then
echo 'Compose did not start the intended image' >&2
false
fi
curl --retry 12 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
"$origin/login" >/dev/null
curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \
"$origin/projects/catch-em-all" >/dev/null
trap - ERR
echo "Deployed $sha to $origin"

View File

@@ -0,0 +1,93 @@
-- Apply after auth-catalog-migration.sql and before deploying the foundation release.
-- Additive domain-schema changes; existing records and numeric IDs are retained.
ALTER TABLE blog_articles ADD COLUMN IF NOT EXISTS tags text[] NOT NULL DEFAULT '{}'::text[];
ALTER TABLE lists ADD COLUMN IF NOT EXISTS slug text;
UPDATE lists SET slug = COALESCE(NULLIF(trim(both '-' from regexp_replace(lower(name), '[^a-z0-9]+', '-', 'g')), ''), 'list') || '-' || id
WHERE slug IS NULL;
ALTER TABLE lists ALTER COLUMN slug SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS lists_slug_unique ON lists (slug);
ALTER TABLE lists ADD COLUMN IF NOT EXISTS is_progress boolean NOT NULL DEFAULT false;
ALTER TABLE list_items ADD COLUMN IF NOT EXISTS picture_url text;
ALTER TABLE list_items ADD COLUMN IF NOT EXISTS description text;
CREATE TABLE IF NOT EXISTS list_item_completions (
id serial PRIMARY KEY,
item_id integer NOT NULL REFERENCES list_items(id) ON DELETE CASCADE,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT list_item_completions_item_id_user_id_unique UNIQUE (item_id, user_id)
);
CREATE INDEX IF NOT EXISTS list_item_completions_user_id_idx ON list_item_completions(user_id);
-- Join/setting records gain the same numeric identity as other site-owned rows.
ALTER TABLE tasks_to_topics ADD COLUMN IF NOT EXISTS id serial;
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'tasks_to_topics'::regclass AND conname = 'tasks_to_topics_pkey'
AND pg_get_constraintdef(oid) NOT LIKE 'PRIMARY KEY (id)') THEN
ALTER TABLE tasks_to_topics DROP CONSTRAINT tasks_to_topics_pkey;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'tasks_to_topics'::regclass AND contype = 'p') THEN
ALTER TABLE tasks_to_topics ADD CONSTRAINT tasks_to_topics_pkey PRIMARY KEY (id);
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS tasks_to_topics_task_id_topic_id_unique ON tasks_to_topics(task_id, topic_id);
ALTER TABLE catch_em_all_settings ADD COLUMN IF NOT EXISTS id serial;
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'catch_em_all_settings'::regclass AND conname = 'catch_em_all_settings_pkey'
AND pg_get_constraintdef(oid) NOT LIKE 'PRIMARY KEY (id)') THEN
ALTER TABLE catch_em_all_settings DROP CONSTRAINT catch_em_all_settings_pkey;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'catch_em_all_settings'::regclass AND contype = 'p') THEN
ALTER TABLE catch_em_all_settings ADD CONSTRAINT catch_em_all_settings_pkey PRIMARY KEY (id);
END IF;
END $$;
CREATE UNIQUE INDEX IF NOT EXISTS catch_em_all_settings_user_id_unique ON catch_em_all_settings(user_id);
-- Every site-owned table records creation and latest update. Domain-specific
-- event times (submitted_at, seen_at, uploaded_at) remain untouched.
DO $$
DECLARE table_name text;
BEGIN
FOREACH table_name IN ARRAY ARRAY[
'blog_articles', 'lists', 'list_items', 'list_item_completions',
'animals', 'sightings', 'photos', 'tasks', 'topics', 'tasks_to_topics',
'pokemon', 'games', 'pokemon_game_entries', 'user_entries', 'user_games',
'catch_em_all_settings'
] LOOP
EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now()', table_name);
EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now()', table_name);
END LOOP;
END $$;
-- Preserve the best available creation time for older Animaldex rows.
UPDATE animals SET created_at = submitted_at WHERE created_at > submitted_at;
UPDATE sightings SET created_at = submitted_at WHERE created_at > submitted_at;
UPDATE photos SET created_at = uploaded_at WHERE created_at > uploaded_at;
CREATE OR REPLACE FUNCTION site_touch_updated_at() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END $$;
DO $$
DECLARE table_name text;
BEGIN
FOREACH table_name IN ARRAY ARRAY[
'blog_articles', 'lists', 'list_items', 'list_item_completions',
'animals', 'sightings', 'photos', 'tasks', 'topics', 'tasks_to_topics',
'pokemon', 'games', 'pokemon_game_entries', 'user_entries', 'user_games',
'catch_em_all_settings'
] LOOP
EXECUTE format('DROP TRIGGER IF EXISTS site_touch_updated_at ON %I', table_name);
EXECUTE format('CREATE TRIGGER site_touch_updated_at BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION site_touch_updated_at()', table_name);
END LOOP;
END $$;

View File

@@ -0,0 +1,14 @@
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const sql = postgres(process.env.DB_URL, { max: 1 });
try {
await sql.begin(async (tx) => {
await tx.unsafe(
await readFile(new URL('./auth-catalog-migration.sql', import.meta.url), 'utf8')
);
});
console.log('Authentication and catalog migration applied');
} finally {
await sql.end();
}

View File

@@ -0,0 +1,13 @@
import { readFile } from 'node:fs/promises';
import postgres from 'postgres';
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1 });
try {
await client.begin(async (tx) => {
await tx.unsafe(await readFile(new URL('./foundation-migration.sql', import.meta.url), 'utf8'));
});
console.log('Foundation migration applied');
} finally {
await client.end();
}

84
scripts/seed-catalog.ts Normal file
View File

@@ -0,0 +1,84 @@
import { readFile } from 'node:fs/promises';
import { drizzle } from 'drizzle-orm/postgres-js';
import { and, eq, isNull, sql } from 'drizzle-orm';
import postgres from 'postgres';
import { pokemon, games, pokemonGameEntries } from '../src/lib/server/db/schema/catch-em-all';
type Catalog = {
source: { revision: string };
pokemon: (typeof pokemon.$inferInsert)[];
games: { sourceId: number; name: string; generation: number }[];
entries: [number, number, 'catchable' | 'unknown'][];
};
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const catalog: Catalog = JSON.parse(
await readFile(new URL('../data/pokemon-catalog.json', import.meta.url), 'utf8')
);
const client = postgres(process.env.DB_URL, { max: 1 });
const db = drizzle({ client });
try {
await db.transaction(async (tx) => {
const pokemonIds = new Map<number, number>();
const gameIds = new Map<number, number>();
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);
}
for (let offset = 0; offset < catalog.entries.length; offset += 1000) {
const rows = catalog.entries
.slice(offset, offset + 1000)
.map(([number, version, availability]) => {
const pokemonId = pokemonIds.get(number),
gameId = gameIds.get(version);
if (!pokemonId || !gameId)
throw new Error(`Unknown catalog reference ${number}/${version}`);
return {
pokemonId,
gameId,
availability,
note:
availability === 'catchable'
? 'PokéAPI: documented encounter; conditions, location, or a specific form may be required.'
: 'PokéAPI: Pokédex or game-index record only; catch, evolution, gift, trade, transfer, and event availability is not confirmed.'
};
});
await tx
.insert(pokemonGameEntries)
.values(rows)
.onConflictDoUpdate({
target: [pokemonGameEntries.pokemonId, pokemonGameEntries.gameId],
set: { availability: sql`excluded.availability`, note: sql`excluded.note` },
// Preserve hand-maintained availability and notes.
setWhere: sql`${pokemonGameEntries.note} like 'PokéAPI:%'`
});
}
});
console.log(
`Seeded ${catalog.pokemon.length} species, ${catalog.games.length} versions, ${catalog.entries.length} entries from ${catalog.source.revision}`
);
} finally {
await client.end();
}

15
scripts/set-admin.ts Normal file
View File

@@ -0,0 +1,15 @@
import postgres from 'postgres';
const [userId, role = 'admin'] = process.argv.slice(2);
if (!userId || !['admin', 'user'].includes(role))
throw new Error('Usage: bun scripts/set-admin.ts <user-id> [admin|user]');
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const sql = postgres(process.env.DB_URL, { max: 1 });
try {
const users =
await sql`update "user" set role = ${role}, updated_at = now() where id = ${userId} returning id, role`;
if (!users.length)
throw new Error('User ID not found. Register first and copy the ID from your account page.');
console.log(users[0]);
} finally {
await sql.end();
}

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
sha=${1:?staging commit required}
case "$sha" in
*[!0-9a-f]*|'') echo 'Invalid image tag' >&2; exit 2 ;;
esac
directory=/opt/personal-website-staging
tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
if [ "$tag" != "$sha" ]; then
echo "Staging runs $tag, not $sha" >&2
exit 1
fi
image="git.stanrunge.dev/stan/personal-website:$sha"
container=$(sudo docker compose --env-file "$directory/.env" -f "$directory/compose.prod.yml" ps -q app)
expected=$(sudo docker image inspect -f '{{.Id}}' "$image")
actual=$(sudo docker inspect -f '{{.Image}}' "$container")
if [ "$actual" != "$expected" ]; then
echo 'Staging container differs from the tested image' >&2
exit 1
fi
curl --fail --silent --show-error https://staging.stanrunge.dev/login >/dev/null
echo "Verified staging image $sha"