1#!/usr/bin/env node
Jev's decision history for one song: how often it picks each option, section by section, across every stored performance (worker/src/listening.ts; the rows are performance_segments, worker/migrations/0003_listening.sql).
nix run .#jev-history -- lightning-in-a-bottle the local dev database nix run .#jev-history -- jev/dial-up --current only the song's current code nix run .#jev-history -- jev/dial-up --remote the deployed database
Local reads //:dev's database (worker/.wrangler/state) with the
devshell's wrangler, from the working tree it is run in. --remote reads
the real one through the deploy's 1Password-fed wrangler (the flake
passes it as JEV_HISTORY_WRANGLER): on a temporary copy of worker/ with
the database's id looked up by name, read-only (nothing is created), and
the token never passes through this process.
The form's question (walk()) is counted by the section playing when Jev picked what comes next ("at drop2, Jev picks outro 50%"); every other choice by the section it plays in. Only Jev's own picks count; a fallback, a forced section, or a part absent from its section is shown beside them as a count.
23import { execFileSync } from 'node:child_process'; 24import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; 25import { tmpdir } from 'node:os'; 26import { join } from 'node:path'; 27import { fileURLToPath } from 'node:url'; 28import { createHash } from 'node:crypto'; 29import { songCode } from '../../website/src/jev/songCode.mjs'; 30import { parseList } from '../deploy/resources.mjs';
"<theme>/<song>", from either that or a song's folder name.
36export function songId(arg, songsRoot) { 37 if (SONG_ID.test(arg ?? '')) return arg; 38 if (!/^[a-z0-9-]{1,64}$/.test(arg ?? '')) throw new Error(`not a song: ${arg}`); 39 const themes = readdirSync(songsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()); 40 const found = themes.map((t) => `${t.name}/${arg}`).filter((id) => existsSync(join(songsRoot, id, 'SPEC.md'))); 41 if (found.length !== 1) throw new Error(found.length ? `${arg} is in several themes: ${found.join(', ')}` : `no song ${arg}`); 42 return found[0]; 43}
The SHA-256 of the song's code as the page plays it (songCode.mjs), which is what a performance records as its codeHash.
47export function currentHash(songDir) { 48 const raw = readFileSync(join(songDir, 'song.js'), 'utf8'); 49 const fm = readFileSync(join(songDir, 'SPEC.md'), 'utf8').split(/^---\s*$/m)[1] ?? ''; 50 const title = fm 51 .match(/^title:\s*(.+)$/m)?.[1] 52 ?.trim() 53 .replace(/^(['"])(.*)\1$/, '$2'); 54 return createHash('sha256').update(songCode(raw, title)).digest('hex'); 55}
Song ids and hashes are checked against their patterns, so no quote can reach the SQL (wrangler's d1 execute takes no bound parameters).
64export function summaryQuery(song, hash) { 65 return `SELECT count(*) AS performances, coalesce(sum(ended = 'finished'), 0) AS finished, 66 count(DISTINCT code_hash) AS versions, coalesce(sum(code_hash = ${literal(hash, HASH)}), 0) AS current 67FROM performances WHERE song = ${literal(song, SONG_ID)}`; 68}
Per (how asked, section, question, option, whether it was Jev's own pick): a count.
71export function historyQuery(song, { onlyHash = null } = {}) { 72 const isForm = 's.jev = p.form_jev AND a.key = p.form_question'; 73 return `SELECT 74 CASE WHEN ${isForm} THEN 'next' ELSE 'in' END AS asked, 75 CASE WHEN ${isForm} THEN s.after_section ELSE coalesce(s.section, '#' || (s.segment + 1)) END AS at, 76 a.key AS question, 77 json_extract(a.value, '$.choice') AS choice, 78 CASE 79 -- a forced answer is marked a fallback too (jevCore), but was no one's pick 80 WHEN json_extract(a.value, '$.forced') THEN 'forced' 81 WHEN json_extract(a.value, '$.absent') THEN 'absent' 82 WHEN s.status <> 'answered' OR json_extract(a.value, '$.fallback') THEN 'fallback' 83 ELSE 'jev' 84 END AS kind, 85 count(*) AS n 86FROM performances p 87 JOIN performance_segments s ON s.performance = p.id, 88 json_each(s.answers) a 89WHERE p.song = ${literal(song, SONG_ID)} AND json_extract(a.value, '$.choice') IS NOT NULL 90 ${onlyHash ? `AND p.code_hash = ${literal(onlyHash, HASH)}` : ''} 91GROUP BY asked, at, question, choice, kind`; 92}
94const pct = (n, of) => `${Math.round((n / of) * 100)}%`; 95 96export function format(song, summary, rows) { 97 const s = summary ?? { performances: 0 }; 98 const lines = [ 99 `${song}: ${s.performances} performance${s.performances === 1 ? '' : 's'}` + 100 (s.performances 101 ? ` (${s.finished} finished, ${s.performances - s.finished} stopped), ${s.versions} version${s.versions === 1 ? '' : 's'} of its code (${s.current} of the current one)` 102 : ''), 103 ]; 104 // question → asked → at → { picks: Map(choice → n), other: { fallback, forced, absent } } 105 const questions = new Map(); 106 for (const r of rows) { 107 if (r.at === null) continue; // the opening's section: the form's start, not a pick 108 const q = questions.get(r.question) ?? new Map(); 109 questions.set(r.question, q); 110 const key = `${r.asked}\u0000${r.at}`; 111 const at = q.get(key) ?? { asked: r.asked, at: r.at, picks: new Map(), other: {}, total: 0 }; 112 q.set(key, at); 113 at.total += r.n; 114 if (r.kind === 'jev') at.picks.set(r.choice, (at.picks.get(r.choice) ?? 0) + r.n); 115 else at.other[r.kind] = (at.other[r.kind] ?? 0) + r.n; 116 } 117 for (const [question, ats] of questions) { 118 const groups = [...ats.values()].sort((a, b) => b.total - a.total || a.at.localeCompare(b.at)); 119 const next = groups[0]?.asked === 'next'; 120 lines.push( 121 '', 122 next 123 ? `${question}: what Jev picks next, at each section (Jev's own picks)` 124 : `${question}: what Jev picks, in each section (Jev's own picks)`, 125 ); 126 const width = Math.max(...groups.map((g) => g.at.length)); 127 for (const g of groups) { 128 const jevs = [...g.picks.values()].reduce((a, b) => a + b, 0); 129 const picks = [...g.picks.entries()] 130 .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) 131 .map(([choice, n]) => `${choice} ${pct(n, jevs)} (${n})`) 132 .join(' · '); 133 const other = Object.entries(g.other) 134 .map(([kind, n]) => `${n} ${kind === 'fallback' && n > 1 ? 'fallbacks' : kind}`) 135 .join(', '); 136 lines.push(` ${g.at.padEnd(width)} ${next ? '→ ' : ''}${picks || '(no picks of its own)'}${other ? ` + ${other}` : ''}`); 137 } 138 } 139 if (!questions.size && s.performances) lines.push('', 'no choices recorded'); 140 return lines.join('\n'); 141}
The results of one statement from wrangler d1 execute --json.
150const CONFIG = 'worker/wrangler.json'; 151const databaseName = (root) => JSON.parse(readFileSync(join(root, CONFIG), 'utf8')).d1_databases[0].database_name; 152 153function localQuery(root) { 154 const name = databaseName(root); 155 return (sql) => 156 results( 157 execFileSync( 158 'wrangler', 159 ['d1', 'execute', name, '--local', '--env', 'dev', '--config', CONFIG, '--json', '--command', sql], 160 { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], env: { ...process.env, WRANGLER_SEND_METRICS: 'false' } }, 161 ), 162 ); 163}
The deployed database, through the deploy's wrangler (<wrangler> <dir> <args…>), on a copy of worker/ given the database's id. Only reads.
167function remoteQuery(root) { 168 const wrangler = process.env.JEV_HISTORY_WRANGLER; 169 if (!wrangler) throw new Error('--remote needs JEV_HISTORY_WRANGLER; run it as `nix run .#jev-history -- … --remote`'); 170 const work = mkdtempSync(join(tmpdir(), 'jev-history-')); 171 process.on('exit', () => rmSync(work, { recursive: true, force: true })); 172 cpSync(join(root, 'worker'), work, { recursive: true, filter: (src) => !src.includes('.wrangler') }); 173 const run = (args) => 174 execFileSync(wrangler, [work, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], env: { ...process.env, WRANGLER_SEND_METRICS: 'false' } }); 175 const config = JSON.parse(readFileSync(join(work, 'wrangler.json'), 'utf8')); 176 const db = config.d1_databases[0]; 177 const found = parseList(run(['d1', 'list', '--json']), 'd1 list').find((d) => d?.name === db.database_name); 178 if (!found) throw new Error(`the account has no D1 database ${db.database_name}; nothing has been recorded there`); 179 db.database_id = found.uuid; 180 writeFileSync(join(work, 'wrangler.json'), JSON.stringify(config, null, 2)); 181 return (sql) => results(run(['d1', 'execute', db.database_name, '--remote', '--json', '--command', sql])); 182}
184async function main(args) { 185 const flags = new Set(args.filter((a) => a.startsWith('--'))); 186 const [arg] = args.filter((a) => !a.startsWith('--')); 187 const unknown = [...flags].filter((f) => !['--remote', '--current'].includes(f)); 188 if (!arg || unknown.length) throw new Error('usage: jev-history <song> [--current] [--remote]'); 189 const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); 190 const songsRoot = join(root, 'songs'); 191 const song = songId(arg, songsRoot); 192 const hash = currentHash(join(songsRoot, song)); 193 const query = flags.has('--remote') ? remoteQuery(root) : localQuery(root); 194 const [summary] = query(summaryQuery(song, hash)); 195 const rows = query(historyQuery(song, { onlyHash: flags.has('--current') ? hash : null })); 196 console.log(`${flags.has('--remote') ? 'the deployed database' : 'the local dev database'}${flags.has('--current') ? ', the current code only' : ''}\n`); 197 console.log(format(song, summary, rows)); 198} 199 200if (process.argv[1] === fileURLToPath(import.meta.url)) { 201 main(process.argv.slice(2)).catch((e) => { 202 console.error(`jev-history: ${e.message}`); 203 process.exit(1); 204 }); 205}