cs-prices: full ByMykel catalog, every config, daily multi-source prices, item pages with working charts
All checks were successful
Deploy production / deploy (push) Successful in 1m18s

- cs.skins catalog (weapons, knives, gloves, stickers, cases, agents, charms, …) refreshed weekly
- every market config: wears in float range, StatTrak, Souvenir, Doppler/Gamma phases
- cs.daily_prices (one row per config/stream/day, idempotent) from Skinport, Steam, CSFloat, Buff163
- cs.price_windows for 24h/7d/30d/90d sale aggregates, cs.fx_rates (ECB)
- scripts/cs-prices.ts daily|catalog|prices; legacy ingest fixed for the new unique index
- skin browser + /projects/cs-prices/[slug] with variant/wear/phase switcher and real charts
- charts show points for short histories and pad sparse time axes
This commit is contained in:
2026-10-26 08:47:18 +00:00
parent 9d79dfb43a
commit 9e89a615c6
16 changed files with 44371 additions and 99 deletions

View File

@@ -5,11 +5,45 @@ import {
text,
timestamp,
uniqueIndex,
bigint
bigint,
boolean,
date,
real,
smallint,
numeric
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { csSchema } from './schemas';
import { id } from './helpers';
// One finish or item, grouping all of its market configs (wear, StatTrak, Souvenir, phase).
// Ids come from ByMykel/CSGO-API (skin_id / item id); items only seen in price feeds get `mkt-<slug>`.
export const csSkins = csSchema.table(
'skins',
{
id: text('id').primaryKey(),
slug: text('slug').notNull(),
name: text('name').notNull(),
kind: text('kind').notNull(),
weapon: text('weapon'),
pattern: text('pattern'),
category: text('category'),
rarity: text('rarity'),
rarityColor: text('rarity_color'),
image: text('image'),
minFloat: real('min_float'),
maxFloat: real('max_float'),
collections: text('collections').array(),
source: text('source').notNull().default('bymykel'),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('skins_slug_idx').on(table.slug),
index('skins_name_idx').on(table.name),
index('skins_kind_idx').on(table.kind)
]
);
// A Steam market hash name identifies a listing, including wear and special variants.
export const csMarketItems = csSchema.table(
'items',
@@ -19,12 +53,20 @@ export const csMarketItems = csSchema.table(
name: text('name').notNull(),
iconUrl: text('icon_url'),
itemType: text('item_type'),
// Catalog link and config facets. Doppler phases share a market hash name, so a phase
// row is its own config next to the phase-less listing.
skinId: text('skin_id').references(() => csSkins.id, { onDelete: 'set null' }),
phase: text('phase'),
wear: text('wear'),
stattrak: boolean('stattrak').notNull().default(false),
souvenir: boolean('souvenir').notNull().default(false),
firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull().defaultNow(),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('items_hash_idx').on(table.marketHashName),
index('items_name_idx').on(table.name)
uniqueIndex('items_config_idx').on(table.marketHashName, sql`coalesce(${table.phase}, '')`),
index('items_name_idx').on(table.name),
index('items_skin_idx').on(table.skinId)
]
);
@@ -82,9 +124,64 @@ export const csPriceStreams = csSchema.table(
{
source: text('source').notNull(),
currency: text('currency').notNull(),
kind: text('kind').notNull()
kind: text('kind').notNull(),
id: smallint('id').generatedByDefaultAsIdentity(),
label: text('label'),
lastFetchedAt: timestamp('last_fetched_at', { withTimezone: true }),
lastRows: integer('last_rows'),
lastError: text('last_error')
},
(table) => [
primaryKey({ name: 'price_streams_pkey', columns: [table.source, table.currency, table.kind] })
primaryKey({ name: 'price_streams_pkey', columns: [table.source, table.currency, table.kind] }),
uniqueIndex('price_streams_id_idx').on(table.id)
]
);
// One price per config per stream per UTC day (idempotent upsert). Prices stay in the stream's
// own currency; fx_rates converts USD streams to EUR for display.
export const csDailyPrices = csSchema.table(
'daily_prices',
{
itemId: integer('item_id')
.notNull()
.references(() => csMarketItems.id, { onDelete: 'cascade' }),
streamId: smallint('stream_id').notNull(),
day: date('day').notNull(),
priceCents: integer('price_cents').notNull(),
volume: integer('volume')
},
(table) => [
primaryKey({ name: 'daily_prices_pkey', columns: [table.itemId, table.streamId, table.day] }),
index('daily_prices_day_idx').on(table.day, table.streamId)
]
);
// Rolling sale aggregates (24h/7d/30d/90d) as reported by the source, latest snapshot only.
export const csPriceWindows = csSchema.table(
'price_windows',
{
itemId: integer('item_id')
.notNull()
.references(() => csMarketItems.id, { onDelete: 'cascade' }),
source: text('source').notNull(),
currency: text('currency').notNull(),
span: text('span').notNull(),
medianCents: integer('median_cents'),
avgCents: integer('avg_cents'),
minCents: integer('min_cents'),
maxCents: integer('max_cents'),
volume: integer('volume'),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
primaryKey({
name: 'price_windows_pkey',
columns: [table.itemId, table.source, table.span]
})
]
);
export const csFxRates = csSchema.table('fx_rates', {
day: date('day').primaryKey(),
usdEur: numeric('usd_eur', { precision: 12, scale: 8 }).notNull()
});