jevstrudel.git / tools / jev-history / history.mjs
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';
32const SONG_ID = /^[a-z0-9-]{1,64}\/[a-z0-9-]{1,64}$/;
33const HASH = /^[0-9a-f]{64}$/;

"<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).

59const literal = (s, pattern) => {
60  if (!pattern.test(s)) throw new Error(`refusing to query with ${s}`);
61  return `'${s}'`;
62};
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.

144export function results(out) {
145  const [first] = parseList(out, 'd1 execute');
146  if (!first?.success) throw new Error(`the query failed: ${JSON.stringify(first).slice(0, 300)}`);
147  return first.results;
148}
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}