app: lists on meta tables, schema-qualified SQL, typed columns

- lists read/write real lists.* tables via meta.tables/meta.columns and the
  SECURITY DEFINER meta functions; typed extra columns in the item forms
- old numeric list URLs 308-redirect to the slug
- include_recurring is boolean, osu accuracy/pp/rate numeric
- raw SQL qualified with the new schemas
This commit is contained in:
2026-10-23 22:03:42 +00:00
parent 64fb8df262
commit 4cfe6bad6c
25 changed files with 529 additions and 218 deletions

View File

@@ -3,9 +3,10 @@ import postgres from 'postgres';
// Read-only fingerprints: lets a deployment verify saved progress without logging it.
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1 });
// All saved progress lives in schema `games` since the schema reorg.
const tables = [
'user_entries',
'user_games',
'catch_em_all_entries',
'catch_em_all_games',
'catch_em_all_settings',
'pokemon_journeys',
'pokemon_catches',
@@ -20,11 +21,11 @@ const tables = [
try {
const result: Record<string, unknown> = {};
for (const table of tables) {
const [exists] = await client`select to_regclass(${'public.' + table}) is not null as present`;
const [exists] = await client`select to_regclass(${'games.' + table}) is not null as present`;
if (!exists.present) continue;
const [row] = await client.unsafe(`select count(*)::int as rows,
md5(coalesce(string_agg(record, E'\\n' order by record), '')) as fingerprint
from (select row_to_json(t)::text as record from "${table}" t) s`);
from (select row_to_json(t)::text as record from games."${table}" t) s`);
result[table] = row;
}
console.log(JSON.stringify(result, null, 2));

View File

@@ -85,7 +85,7 @@ async function itemId(
extraDimensions: Dimensions = {}
) {
const [row] = await sql<{ id: number }[]>`
INSERT INTO cs_market_items (market_hash_name, name, icon_url, item_type)
INSERT INTO cs.items (market_hash_name, name, icon_url, item_type)
VALUES (${name}, ${displayName}, ${iconUrl ?? null}, ${itemType ?? null})
ON CONFLICT (market_hash_name) DO UPDATE SET
name = EXCLUDED.name,
@@ -98,13 +98,13 @@ async function itemId(
for (const [key, value] of Object.entries(inferred)) {
if (!/^[a-z][a-z0-9_]*$/.test(key) || !value) continue;
await sql`
INSERT INTO cs_item_dimensions (item_id, key, value) VALUES (${row.id}, ${key}, ${value})
INSERT INTO cs.item_dimensions (item_id, key, value) VALUES (${row.id}, ${key}, ${value})
ON CONFLICT (item_id, key) DO NOTHING`;
}
for (const [key, value] of Object.entries(extraDimensions)) {
if (!/^[a-z][a-z0-9_]*$/.test(key) || !value) continue;
await sql`
INSERT INTO cs_item_dimensions (item_id, key, value) VALUES (${row.id}, ${key}, ${value})
INSERT INTO cs.item_dimensions (item_id, key, value) VALUES (${row.id}, ${key}, ${value})
ON CONFLICT (item_id, key) DO UPDATE SET value = EXCLUDED.value`;
}
return row.id;
@@ -116,7 +116,7 @@ async function saveObservation(row: Observation, knownItemId?: number) {
(await itemId(row.marketHashName, row.name, undefined, undefined, row.dimensions));
await ensureStream(row.source, row.currency, row.kind);
await sql`
INSERT INTO cs_price_observations
INSERT INTO cs.price_observations
(item_id, source, currency, observed_at, price_cents, volume, kind)
VALUES (${item}, ${row.source}, ${row.currency}, ${new Date(row.observedAt)},
${row.priceCents}, ${row.volume ?? null}, ${row.kind})
@@ -128,7 +128,7 @@ async function saveObservation(row: Observation, knownItemId?: number) {
async function ensureStream(source: string, currency: string, kind: string) {
const key = `${source}\0${currency}\0${kind}`;
if (knownStreams.has(key)) return;
await sql`INSERT INTO cs_price_streams (source, currency, kind)
await sql`INSERT INTO cs.price_streams (source, currency, kind)
VALUES (${source}, ${currency}, ${kind}) ON CONFLICT DO NOTHING`;
knownStreams.add(key);
}
@@ -150,7 +150,7 @@ async function saveHistoryRows(
kind: 'sale-median'
}));
for (let offset = 0; offset < rows.length; offset += 500) {
await sql`INSERT INTO cs_price_observations ${sql(rows.slice(offset, offset + 500))}
await sql`INSERT INTO cs.price_observations ${sql(rows.slice(offset, offset + 500))}
ON CONFLICT (item_id, source, currency, observed_at, kind) DO UPDATE SET
price_cents = EXCLUDED.price_cents, volume = EXCLUDED.volume`;
}
@@ -242,7 +242,7 @@ async function history() {
if (!process.env.STEAM_SESSIONID || !process.env.STEAM_LOGIN_SECURE)
throw new Error('STEAM_SESSIONID and STEAM_LOGIN_SECURE are required for Steam history');
const items = await sql<{ id: number; market_hash_name: string }[]>`
SELECT id, market_hash_name FROM cs_market_items ORDER BY id LIMIT ${limit} OFFSET ${start}`;
SELECT id, market_hash_name FROM cs.items ORDER BY id LIMIT ${limit} OFFSET ${start}`;
let done = 0;
for (const item of items) {
const url = new URL('https://steamcommunity.com/market/pricehistory/');

View File

@@ -8,11 +8,11 @@ try {
await sql.begin(async (tx) => {
for (const image of artwork) {
const rows =
await tx`update tracker_items i set icon=${image.path} from tracker_games g where i.game_id=g.id and g.slug=${image.game} and i.key=${image.key} returning i.id`;
await tx`update ref.tracker_items i set icon=${image.path} from ref.tracker_games g where i.game_id=g.id and g.slug=${image.game} and i.key=${image.key} returning i.id`;
if (rows.length !== 1) throw new Error(`Unknown artwork target: ${image.game}/${image.key}`);
}
for (const [source, path] of Object.entries(achievementArt)) {
await tx`update tracker_items set icon=${path} where kind='achievement' and icon=${source}`;
await tx`update ref.tracker_items set icon=${path} where kind='achievement' and icon=${source}`;
}
});
console.log(

View File

@@ -22,17 +22,17 @@ try {
}[]
][]) {
const [game] =
await tx`select id from tracker_games where slug=${slug} and franchise='call-of-duty'`;
await tx`select id from ref.tracker_games where slug=${slug} and franchise='call-of-duty'`;
if (!game) throw new Error(`Seed Call of Duty catalog first: ${slug}`);
for (let offset = 0; offset < rows.length; offset += 500) {
const batch = rows
.slice(offset, offset + 500)
.map(({ sourceUrl, ...r }) => ({ ...r, game_id: game.id, source_url: sourceUrl }));
await tx`insert into tracker_challenges ${tx(batch)} on conflict (game_id,key) do update set name=excluded.name,category=excluded.category,requirement=excluded.requirement,reward=excluded.reward,icon=excluded.icon,target=excluded.target,source_url=excluded.source_url`;
await tx`insert into ref.tracker_challenges ${tx(batch)} on conflict (game_id,key) do update set name=excluded.name,category=excluded.category,requirement=excluded.requirement,reward=excluded.reward,icon=excluded.icon,target=excluded.target,source_url=excluded.source_url`;
}
// Retire removed source definitions only when nobody has saved progress.
if (rows.length)
await tx`delete from tracker_challenges c where c.game_id=${game.id} and c.key not in ${tx(rows.map((r) => r.key))} and not exists (select 1 from tracker_challenge_progress p where p.challenge_id=c.id)`;
await tx`delete from ref.tracker_challenges c where c.game_id=${game.id} and c.key not in ${tx(rows.map((r) => r.key))} and not exists (select 1 from games.tracker_challenge_progress p where p.challenge_id=c.id)`;
console.log(`${slug}: ${rows.length} challenge rows`);
}
});

View File

@@ -9,10 +9,10 @@ const client = postgres(process.env.DB_URL, { max: 1 });
try {
await client.begin(async (tx) => {
const species = new Map(
(await tx`select id, number from pokemon`).map((r) => [r.number, r.id])
(await tx`select id, number from ref.pokemon`).map((r) => [r.number, r.id])
);
const games = new Map(
(await tx`select id, source_id from games`).map((r) => [r.source_id, r.id])
(await tx`select id, source_id from ref.pokemon_games`).map((r) => [r.source_id, r.id])
);
const speciesId = (n: number) => {
const id = species.get(n);
@@ -25,10 +25,10 @@ try {
return id;
};
for (const row of catalog.facts)
await tx`insert into pokemon_research (pokemon_id,facts) values (${speciesId(row.number)},${tx.json(row.facts)}) on conflict (pokemon_id) do update set facts=excluded.facts`;
await tx`insert into ref.pokemon_research (pokemon_id,facts) values (${speciesId(row.number)},${tx.json(row.facts)}) on conflict (pokemon_id) do update set facts=excluded.facts`;
for (const row of catalog.games) {
if (!games.has(row.sourceId)) continue;
await tx`insert into pokemon_game_research (game_id,version_group,regions) values (${gameId(row.sourceId)},${row.versionGroup},${row.regions}) on conflict (game_id) do update set version_group=excluded.version_group,regions=excluded.regions`;
await tx`insert into ref.pokemon_game_research (game_id,version_group,regions) values (${gameId(row.sourceId)},${row.versionGroup},${row.regions}) on conflict (game_id) do update set version_group=excluded.version_group,regions=excluded.regions`;
}
for (let offset = 0; offset < catalog.dexes.length; offset += 1000) {
const batch = catalog.dexes
@@ -42,10 +42,10 @@ try {
number
}));
if (batch.length)
await tx`insert into pokemon_dex_numbers ${tx(batch)} on conflict (pokemon_id,game_id,dex) do update set number=excluded.number,dex_name=excluded.dex_name`;
await tx`insert into ref.pokemon_dex_numbers ${tx(batch)} on conflict (pokemon_id,game_id,dex) do update set number=excluded.number,dex_name=excluded.dex_name`;
}
for (const row of catalog.moves)
await tx`insert into pokemon_moves ${tx(row)} on conflict (id) do update set name=excluded.name,type=excluded.type,category=excluded.category,power=excluded.power,accuracy=excluded.accuracy,pp=excluded.pp,priority=excluded.priority,target=excluded.target`;
await tx`insert into ref.pokemon_moves ${tx(row)} on conflict (id) do update set name=excluded.name,type=excluded.type,category=excluded.category,power=excluded.power,accuracy=excluded.accuracy,pp=excluded.pp,priority=excluded.priority,target=excluded.target`;
for (let offset = 0; offset < catalog.learnsets.length; offset += 500) {
const batch = catalog.learnsets
.slice(offset, offset + 500)
@@ -54,7 +54,7 @@ try {
version_group,
moves: tx.json(moves)
}));
await tx`insert into pokemon_learnsets ${tx(batch)} on conflict (pokemon_id,version_group) do update set moves=excluded.moves`;
await tx`insert into ref.pokemon_learnsets ${tx(batch)} on conflict (pokemon_id,version_group) do update set moves=excluded.moves`;
}
console.log(
`Seeded ${catalog.facts.length} profiles, ${catalog.dexes.length} dex entries, ${catalog.moves.length} moves and ${catalog.learnsets.length} versioned learnsets`

View File

@@ -6,7 +6,7 @@ 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`;
await sql`update auth."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]);