Files
masi a07522a189 MAGIK — Commander deck builder
React 19 SPA + node:http/node:sqlite API in one container. Card data is
baked into the image from a Scryfall bulk build, so the runtime never calls
Scryfall. No server-side user state: decks live in the browser and travel in
the share URL, which is why there is no PVC and replicas: 1 is correct.

Adds a README covering the architecture and the reasoning behind it, and
publishes the project skills under .claude/skills/.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 21:12:19 -03:00

265 lines
8.9 KiB
JavaScript

/**
* Read-only card index access.
*
* Everything the browser needs to browse and filter Commander-legal cards is
* answered from `data/cards.db`. The database is built by
* `scripts/build-cards.mjs` and is never written to at runtime.
*/
import { DatabaseSync } from 'node:sqlite'
import { existsSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const DB_PATH = process.env.CARDS_DB ?? resolve(ROOT, 'data/cards.db')
let db = null
export function cards() {
if (!db) {
if (!existsSync(DB_PATH)) {
throw new Error(
`card index missing at ${DB_PATH} — run \`pnpm cards:build\` to create it`,
)
}
db = new DatabaseSync(DB_PATH, { readOnly: true })
}
return db
}
const CARD_COLUMNS = `
id, name, mana_cost, cmc, type_line, oracle_text, color_identity, colors,
rarity, power, toughness, loyalty, layout, faces, released_at, artist,
set_name, set_code, commander_legality, commander_eligible, is_partner,
is_basic_land, legal_formats, image_small, image_normal, image_large,
image_png, image_art_crop, image_board_crop
`
/** Shapes a database row into the JSON the client consumes. */
function hydrate(row) {
if (!row) return null
return {
id: row.id,
name: row.name,
manaCost: row.mana_cost,
cmc: row.cmc,
typeLine: row.type_line,
oracleText: row.oracle_text,
colorIdentity: row.color_identity,
colors: row.colors,
rarity: row.rarity,
power: row.power,
toughness: row.toughness,
loyalty: row.loyalty,
faces: row.faces ? JSON.parse(row.faces) : undefined,
releasedAt: row.released_at,
artist: row.artist,
setName: row.set_name,
setCode: row.set_code,
bannedInCommander: row.commander_legality === 'banned',
commanderEligible: row.commander_eligible === 1,
isPartner: row.is_partner === 1,
isBasicLand: row.is_basic_land === 1,
legalFormats: JSON.parse(row.legal_formats),
image: {
small: row.image_small,
normal: row.image_normal,
large: row.image_large,
png: row.image_png,
artCrop: row.image_art_crop,
boardCrop: row.image_board_crop,
},
}
}
const SORTS = {
name: 'name COLLATE NOCASE ASC',
cmc: 'cmc ASC, name COLLATE NOCASE ASC',
'-cmc': 'cmc DESC, name COLLATE NOCASE ASC',
'-name': 'name COLLATE NOCASE DESC',
released: 'released_at DESC, name COLLATE NOCASE ASC',
'-released': 'released_at ASC, name COLLATE NOCASE ASC',
rarity: `CASE rarity
WHEN 'mythic' THEN 0 WHEN 'rare' THEN 1 WHEN 'uncommon' THEN 2 ELSE 3 END,
name COLLATE NOCASE ASC`,
}
const RARITIES = new Set(['common', 'uncommon', 'rare', 'mythic'])
const SUPER_TYPES = new Set(['creature', 'planeswalker', 'artifact', 'enchantment', 'land', 'battle'])
const TYPES = new Set(['instant', 'sorcery', 'adventure', 'tribal', 'conspiracy', 'phenomenon', 'plane', 'scheme', 'vanguard', 'conspiracy'])
const COLORS = new Set(['W', 'U', 'B', 'R', 'G'])
const LEGACIES = new Set(['legal', 'banned', 'not_legal'])
/** Escapes a user string for use inside a LIKE pattern. */
function escapeLike(text) {
return text.replace(/[\\%_]/g, (c) => `\\${c}`)
}
function likePattern(text) {
return `%${escapeLike(text)}%`
}
/**
* Translates validated query parameters into a WHERE clause.
*
* Every value reaching SQL goes through this function, so parameter binding
* stays the only place user data meets the statement.
*/
function buildFilters(q) {
const where = []
const params = []
// An exact name beats a name prefix, which beats a substring or rules-text
// hit, so typing "sol" puts Sol Ring first rather than the alphabetically
// earlier Sol Grail or the "Absolutes" a substring search would surface.
let rank = null
let rankParams = []
const text = (q.get('q') ?? '').trim()
if (text !== '') {
where.push(`(
name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR name LIKE ? ESCAPE '\\' COLLATE NOCASE
OR oracle_text LIKE ? ESCAPE '\\' COLLATE NOCASE
)`)
params.push(`${escapeLike(text)}%`, likePattern(text), likePattern(text))
rank = `CASE WHEN name = ? COLLATE NOCASE THEN 0
WHEN name LIKE ? ESCAPE '\\' COLLATE NOCASE THEN 1
WHEN name LIKE ? ESCAPE '\\' COLLATE NOCASE THEN 2
ELSE 3 END`
rankParams = [text, `${escapeLike(text)}%`, likePattern(text)]
}
const colors = (q.get('colors') ?? '').toUpperCase().split('').filter((c) => COLORS.has(c))
if (colors.length > 0) {
// A card is a match only if it uses no colour outside the selection:
// that is the colour-identity containment a Commander deck needs.
for (const c of 'WUBRG') {
if (colors.includes(c)) continue
where.push(`(color_identity = '' OR instr(color_identity, ?) = 0)`)
params.push(c)
}
}
const supertypes = (q.get('type') ?? '').toLowerCase().split('+').filter(Boolean)
for (const t of supertypes) {
if (SUPER_TYPES.has(t)) {
where.push(`instr(lower(type_line), ?) > 0`)
params.push(t)
}
}
const types = (q.get('subtype') ?? '').toLowerCase().split('+').filter(Boolean)
for (const t of types) {
if (TYPES.has(t)) {
where.push(`instr(lower(type_line), ?) > 0`)
params.push(t)
}
}
const min = Number.parseInt(q.get('cmcMin') ?? '', 10)
if (Number.isFinite(min)) {
where.push('cmc >= ?')
params.push(min)
}
const max = Number.parseInt(q.get('cmcMax') ?? '', 10)
if (Number.isFinite(max)) {
where.push('cmc <= ?')
params.push(max)
}
const rarities = (q.get('rarity') ?? '').toLowerCase().split('+').filter((r) => RARITIES.has(r))
for (const r of rarities) {
where.push('rarity = ?')
params.push(r)
}
const identity = (q.get('identity') ?? '').toUpperCase().split('').filter((c) => COLORS.has(c))
if (identity.length > 0) {
for (const c of 'WUBRG') {
if (identity.includes(c)) continue
where.push(`(color_identity = '' OR instr(color_identity, ?) = 0)`)
params.push(c)
}
}
if (q.get('commander') === '1') where.push('commander_eligible = 1')
if (q.get('partners') === '1') where.push('is_partner = 1')
if (q.get('basic') === '1') where.push('is_basic_land = 1')
// Commander's ban list is small but non-empty, and those cards are
// otherwise indistinguishable in a browser. Default to the legal pool;
// callers opt into `legality=banned` when they want to explain one.
const legality = (q.get('legality') ?? 'legal').toLowerCase()
if (LEGACIES.has(legality)) {
where.push('commander_legality = ?')
params.push(legality)
}
return { sql: where.length ? `WHERE ${where.join(' AND ')}` : '', params, rank, rankParams }
}
export function searchCards(q) {
const { sql: where, params, rank, rankParams } = buildFilters(q)
const limit = Math.min(Math.max(Number.parseInt(q.get('limit') ?? '60', 10) || 60, 1), 200)
const page = Math.max(Number.parseInt(q.get('page') ?? '1', 10) || 1, 1)
const sort = SORTS[q.get('sort') ?? ''] ?? SORTS.name
// Relevance is a prefix on the ordering, not a replacement for it: an
// explicit sort choice still decides the order within each rank.
const order = rank === null ? sort : `${rank}, ${sort}`
const total = cards().prepare(`SELECT count(*) AS n FROM cards ${where}`).get(...params).n
const rows = cards()
.prepare(`SELECT ${CARD_COLUMNS} FROM cards ${where} ORDER BY ${order} LIMIT ? OFFSET ?`)
.all(...params, ...rankParams, limit, (page - 1) * limit)
return { total, page, limit, pages: Math.ceil(total / limit), cards: rows.map(hydrate) }
}
export function getCard(id) {
return hydrate(
cards()
.prepare(`SELECT ${CARD_COLUMNS} FROM cards WHERE id = ?`)
.get(id) ?? null,
)
}
export function getCardsByIds(ids) {
if (ids.length === 0) return []
const placeholders = ids.map(() => '?').join(',')
const rows = cards()
.prepare(`SELECT ${CARD_COLUMNS} FROM cards WHERE id IN (${placeholders})`)
.all(...ids)
const byId = new Map(rows.map((r) => [r.id, hydrate(r)]))
return ids.map((id) => byId.get(id)).filter(Boolean)
}
/**
* Resolves exact card names to records.
*
* Imported decklists and share links carry names, not ids; this turns them
* into real cards in one round trip instead of one search per card.
*/
export function getCardsByNames(names) {
const unique = [...new Set(names.filter((n) => typeof n === 'string' && n !== ''))]
if (unique.length === 0) return []
if (unique.length > 400) unique.length = 400
const placeholders = unique.map(() => '?').join(',')
const rows = cards()
.prepare(`SELECT ${CARD_COLUMNS} FROM cards WHERE name IN (${placeholders})`)
.all(...unique)
const byName = new Map(rows.map((r) => [r.name, hydrate(r)]))
return {
cards: unique.map((name) => byName.get(name)).filter(Boolean),
missing: unique.filter((name) => !byName.has(name)),
}
}
export function cardCount() {
return cards().prepare('SELECT count(*) AS n FROM cards').get().n
}