This commit is contained in:
84
scripts/animaldex-uuid-to-serial.sql
Normal file
84
scripts/animaldex-uuid-to-serial.sql
Normal 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;
|
||||
18
scripts/auth-catalog-migration.sql
Normal file
18
scripts/auth-catalog-migration.sql
Normal 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
|
||||
);
|
||||
96
scripts/build-pokemon-catalog.py
Normal file
96
scripts/build-pokemon-catalog.py
Normal 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()
|
||||
70
scripts/deploy-preflight.ts
Normal file
70
scripts/deploy-preflight.ts
Normal 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
50
scripts/deploy-remote.sh
Normal 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"
|
||||
93
scripts/foundation-migration.sql
Normal file
93
scripts/foundation-migration.sql
Normal 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 $$;
|
||||
14
scripts/migrate-auth-catalog.ts
Normal file
14
scripts/migrate-auth-catalog.ts
Normal 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();
|
||||
}
|
||||
13
scripts/migrate-foundation.ts
Normal file
13
scripts/migrate-foundation.ts
Normal 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
84
scripts/seed-catalog.ts
Normal 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
15
scripts/set-admin.ts
Normal 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();
|
||||
}
|
||||
23
scripts/verify-staging-image.sh
Normal file
23
scripts/verify-staging-image.sh
Normal 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"
|
||||
Reference in New Issue
Block a user