jevstrudel.git / tools / jev-history / history.mjs
1#!/usr/bin/env node
2// Jev's decision history for one song: how often it picks each option,
3// section by section, across every stored performance
4// (worker/src/listening.ts; the rows are performance_segments,
5// worker/migrations/0003_listening.sql).
6//
7//   nix run .#jev-history -- lightning-in-a-bottle          the local dev database
8//   nix run .#jev-history -- jev/dial-up --current          only the song's current code
9//   nix run .#jev-history -- jev/dial-up --remote           the deployed database
10//
11// Local reads `//:dev`'s database (worker/.wrangler/state) with the
12// devshell's wrangler, from the working tree it is run in. --remote reads
13// the real one through the deploy's 1Password-fed wrangler (the flake
14// passes it as JEV_HISTORY_WRANGLER): on a temporary copy of worker/ with
15// the database's id looked up by name, read-only (nothing is created), and
16// the token never passes through this process.
17//
18// The form's question (walk()) is counted by the section playing when Jev
19// picked what comes next ("at drop2, Jev picks outro 50%"); every other
20// choice by the section it plays in. Only Jev's own picks count; a
21// fallback, a forced section, or a part absent from its section is shown
22// 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';
31
32const SONG_ID = /^[a-z0-9-]{1,64}\/[a-z0-9-]{1,64}$/;
33const HASH = /^[0-9a-f]{64}$/;
34
35// "<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}
44
45// The SHA-256 of the song's code as the page plays it (songCode.mjs), which
46// 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}
56
57// Song ids and hashes are checked against their patterns, so no quote can
58// 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};
63
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}
69
70// 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}
93
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}
142
143// 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}
149
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}
164
165// The deployed database, through the deploy's wrangler (`<wrangler> <dir>
166// <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}
183
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}