From 4cfe6bad6c993d1289b1390f412956ada00490cf Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Fri, 23 Oct 2026 22:03:42 +0000 Subject: [PATCH] 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 --- scripts/audit-tracker-data.ts | 9 +- scripts/ingest-cs-prices.ts | 14 +- scripts/seed-cod-art.ts | 4 +- scripts/seed-cod-challenges.ts | 6 +- scripts/seed-pokemon-research.ts | 14 +- scripts/set-admin.ts | 2 +- src/lib/components/ListExtraFields.svelte | 59 ++++ src/lib/money-tracker/charts.test.ts | 4 +- src/lib/money-tracker/charts.ts | 4 +- src/lib/server/lists.ts | 296 ++++++++++++++++++ src/lib/server/slugify.ts | 7 +- src/routes/+page.server.ts | 31 +- src/routes/lists/+page.server.ts | 33 +- src/routes/lists/+page.svelte | 2 +- src/routes/lists/[slug]/+page.server.ts | 148 ++++----- src/routes/lists/[slug]/+page.svelte | 55 ++-- .../projects/animaldex/admin/+page.server.ts | 4 +- .../projects/animaldex/api/map/+server.ts | 2 +- src/routes/projects/cs-prices/+page.server.ts | 12 +- .../projects/cs-prices/api/search/+server.ts | 4 +- .../projects/cs-prices/api/series/+server.ts | 6 +- .../projects/game-tracker/osu/+page.server.ts | 6 +- .../projects/house-tracker/+page.server.ts | 14 +- .../projects/money-tracker/+page.server.ts | 2 +- .../projects/money-tracker/+page.svelte | 9 +- 25 files changed, 529 insertions(+), 218 deletions(-) create mode 100644 src/lib/components/ListExtraFields.svelte create mode 100644 src/lib/server/lists.ts diff --git a/scripts/audit-tracker-data.ts b/scripts/audit-tracker-data.ts index 13de540..44370cd 100644 --- a/scripts/audit-tracker-data.ts +++ b/scripts/audit-tracker-data.ts @@ -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 = {}; 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)); diff --git a/scripts/ingest-cs-prices.ts b/scripts/ingest-cs-prices.ts index f84f99f..a4e5175 100644 --- a/scripts/ingest-cs-prices.ts +++ b/scripts/ingest-cs-prices.ts @@ -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/'); diff --git a/scripts/seed-cod-art.ts b/scripts/seed-cod-art.ts index f2c6d6f..99d70fd 100644 --- a/scripts/seed-cod-art.ts +++ b/scripts/seed-cod-art.ts @@ -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( diff --git a/scripts/seed-cod-challenges.ts b/scripts/seed-cod-challenges.ts index ba1d397..29f43ac 100644 --- a/scripts/seed-cod-challenges.ts +++ b/scripts/seed-cod-challenges.ts @@ -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`); } }); diff --git a/scripts/seed-pokemon-research.ts b/scripts/seed-pokemon-research.ts index ac40e3a..e67bc8d 100644 --- a/scripts/seed-pokemon-research.ts +++ b/scripts/seed-pokemon-research.ts @@ -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` diff --git a/scripts/set-admin.ts b/scripts/set-admin.ts index c123da5..d6b3c90 100644 --- a/scripts/set-admin.ts +++ b/scripts/set-admin.ts @@ -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]); diff --git a/src/lib/components/ListExtraFields.svelte b/src/lib/components/ListExtraFields.svelte new file mode 100644 index 0000000..8995763 --- /dev/null +++ b/src/lib/components/ListExtraFields.svelte @@ -0,0 +1,59 @@ + + +{#each columns as column (column.name)} + {@const name = `extra:${column.name}`} + {#if column.uiType === 'bool'} + + {:else if column.uiType === 'select' && column.options.length} + + {:else if column.uiType === 'longtext'} +