jevstrudel.git / worker / src / listening.ts

Listening, anonymously (website/src/jev/reactions.mjs and performance.mjs):

POST /jev/reactions { song, section, reaction }: one 🔥 or 😴, counted per (song, section, reaction) GET /jev/reactions?song=<id> { song, sections: [{ section, fire, sleep }] } POST /jev/performances a performance (below) → 201 { id } GET /jev/performances/<id> the performance, to replay it GET /jev/performances?song=<id>&offset=&limit= { song, total, takes }: the song's performances, newest first (Take) GET /jev/performances/moves?song=<id> how Jev moves through the song (Moves)

A performance is one play of a song as Jev arranged it: the song, the SHA-256 of its code, and per jev() of the song (in declaration order) per segment what played: its status, how long Jev took, the cycle a late answer played from, and each question's answer. Stored as a row per segment per jev (listening-store.ts), which is also Jev's decision history (nix run .#jev-history, tools/jev-history/). Its id is 16 random bytes, base64url; the data tab lists every performance with its id (data.ts), so its replay link is public.

A performance names who played it when they were signed in: the account comes from the session cookie (auth.ts's signedIn), never from the body, and like every row it is public (migrations/0006_performance_players.sql). The song is the deploy's own (votes.ts's knownSongs) or a listener's public song, "listener:<id>" (content-store.ts); section names are only checked for shape, since the Worker has no song's form, so the page uses only the names its song has. Reactions stay the site songs'.

30import { signedIn } from './auth';
31import { d1Accounts } from './accounts-store';
32import { d1Content } from './content-store';
33import type { Env } from './env';
34import { knownSongs } from './votes';
35import config from '../wrangler.json';
37export const REACTIONS_PATH = '/jev/reactions';
38export const PERFORMANCES_PATH = '/jev/performances';

Per visitor, set in wrangler.json, apart from the relay's and the votes' limits so listening never spends a song's Jev calls or a vote. LISTEN_LIMIT: reactions and reads. A listener reacts a few times a section (a section is 10 to 20 s), reads a song's tally when it opens and a performance when a link opens: 60 a minute is several times that. RECORD_LIMIT: storing a performance, once per play that got past its opening, so at most one per opening section (~13 s) even skipping songs: 6 a minute. It bounds what one visitor can write: 6 × MAX_PERFORMANCE_BYTES.

48export const LISTEN_LIMIT = config.ratelimits.find((r) => r.name === 'LISTEN_LIMIT')!.simple;
49export const RECORD_LIMIT = config.ratelimits.find((r) => r.name === 'RECORD_LIMIT')!.simple;
51const MAX_REACTION_BYTES = 512;

A long song (64 segments), two jevs, a handful of questions each with their probabilities is ~30 KB; nothing a page records is near this.

54export const MAX_PERFORMANCE_BYTES = 64 * 1024;
55export const MAX_SEGMENTS = 64;
56export const MAX_JEVS = 4;
57const MAX_QUESTIONS = 16;
58const MAX_OPTIONS = 64;

"<theme>/<song>" as songs.json writes them (votes.ts).

61const SITE_SONG = /^[a-z0-9-]{1,64}\/[a-z0-9-]{1,64}$/;

a listener's song, as the MCPs and the page name it (listeners.mjs)

63export const LISTENER_SONG = /^listener:([A-Za-z0-9_-]{22})$/;

a song whose takes can be read: a site song or a listener's

65const SONG_ID = { test: (s: string) => SITE_SONG.test(s) || LISTENER_SONG.test(s) };

A question's name, as jevCore.mjs allows them.

67const NAME = /^[A-Za-z_]\w{0,63}$/;

An option of a choice or a form section, or a score's level ("0".."9").

69const OPTION = /^[A-Za-z0-9_-]{1,64}$/;
70const SECTION = OPTION;
71const HASH = /^[0-9a-f]{64}$/;
72const MODEL = /^[A-Za-z0-9._-]{1,32}$/;
73export const PERFORMANCE_ID = /^[A-Za-z0-9_-]{22}$/;

/jev/performances/moves: shorter than any id, so it names no performance

75const MOVES = 'moves';

a song's takes, a page at a time

77export const MAX_TAKES = 50;
78const MAX_OFFSET = 100_000;
79const pageNumber = (raw: string | null, fallback: number, lo: number, hi: number) => {
80  if (raw === null || raw === '') return fallback;
81  const n = Number(raw);
82  return Number.isInteger(n) && n >= lo && n <= hi ? n : null;
83};
85export type Reaction = { song: string; section: string; reaction: 'fire' | 'sleep' };
86export type Tally = { section: string; fire: number; sleep: number };

One question's answer as it played (jevCore.mjs), only these fields.

89export type Answer = {
90  choice?: string;
91  score?: number;
92  noul?: number;
93  confidence?: number;
94  probabilities?: Record<string, number>;
95  sampled?: number;
96  top?: string;
97  fallback?: true;
98  forced?: true;
99  absent?: true;
100};
101export type Segment = {
102  status: 'opening' | 'answered' | 'fallback';
103  ms?: number;
104  from?: number;
105  values: Record<string, Answer>;
106};
107export type Performance = {
108  song: string;
109  codeHash: string;
110  model: string;
111  every: number;
112  ended: 'finished' | 'stopped';
113  form: { jev: number; question: string } | null;
114  jevs: { segments: Segment[] }[];
115};

Who played a take: the account signed in when it was stored, or null.

117export type Player = { id: string; name: string } | null;
118export type Stored = Performance & { id: string; day: string; at: number; player: Player };

When and by whom a performance is stored (listening(), from the session). One account's take, on its page: which song (a listener song's newest title with it, since the page has only its own songs' titles).

122export type PlayerTake = Take & { song: string; title: string | null };
123export type Recording = { at: number; player: string | null };

One recorded performance of a song, as its song view lists it: the sections it played (the form's, in order, or null for a song without a form), how many segments, and how many Jev could not answer.

128export type Take = {
129  id: string;
130  day: string;
131  at: number;
132  player: Player;
133  ended: Performance['ended'];
134  codeHash: string;
135  segments: number;
136  fallbacks: number;
137  path: string[] | null;
138};

How Jev moves through a song, over its latest MOVES_WINDOW performances: per section (the form's, or the segment's number without a form), how often it played, how often Jev was asked about it and fell back, its answer times, and the sections Jev picked after it.

144export type SectionMoves = {
145  section: string;
146  plays: number;
147  asked: number;
148  fallbacks: number;
149  ms: { median: number; p90: number } | null;
150  next: { section: string; n: number; share: number }[];
151};
152export type Moves = { song: string; performances: number; of: number; sections: SectionMoves[] };
153export const MOVES_WINDOW = 500;
155export interface ListeningStore {
156  react(r: Reaction): Promise<void>;
157  tally(song: string): Promise<Tally[]>;
158  record(id: string, day: string, p: Performance, by?: Recording): Promise<void>;
159  performance(id: string): Promise<Stored | null>;
160  takes(song: string, limit: number, offset: number): Promise<{ total: number; takes: Take[] }>;
161  playerTakes(userId: string, limit: number): Promise<PlayerTake[]>;
162  moves(song: string): Promise<Moves>;
163}
164
165const isObject = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x);
166const unexpected = (o: Record<string, unknown>, allowed: string[]) =>
167  Object.keys(o).filter((k) => !allowed.includes(k));
168const within = (x: unknown, lo: number, hi: number): x is number =>
169  typeof x === 'number' && Number.isFinite(x) && x >= lo && x <= hi;
170const count = (x: unknown, hi: number): x is number => Number.isInteger(x) && within(x, 0, hi);
171
172export function whyNotReaction(body: unknown, known: ReadonlySet<string>): string | null {
173  if (!isObject(body)) return 'body must be a JSON object';
174  const extra = unexpected(body, ['song', 'section', 'reaction']);
175  if (extra.length) return `unexpected fields: ${extra.join(', ')}`;
176  const { song, section, reaction } = body;
177  if (typeof song !== 'string' || !SITE_SONG.test(song) || !known.has(song)) return 'song must be a song on this site';
178  if (typeof section !== 'string' || !SECTION.test(section)) return 'section must be a section name';
179  if (reaction !== 'fire' && reaction !== 'sleep') return 'reaction is fire or sleep';
180  return null;
181}

null when a is one answer as jevCore.mjs plays it; otherwise why not.

184function whyNotAnswer(a: unknown): string | null {
185  if (!isObject(a)) return 'an answer is an object';
186  const extra = unexpected(a, [
187    'choice',
188    'score',
189    'noul',
190    'confidence',
191    'probabilities',
192    'sampled',
193    'top',
194    'fallback',
195    'forced',
196    'absent',
197  ]);
198  if (extra.length) return `unexpected answer fields: ${extra.join(', ')}`;
199  const kinds = ['choice', 'score', 'noul'].filter((k) => k in a);
200  if (kinds.length !== 1) return 'an answer has exactly one of choice, score or noul';
201  if ('choice' in a && !(typeof a.choice === 'string' && OPTION.test(a.choice))) return 'a choice is an option name';
202  if ('score' in a && !within(a.score, 0, 9)) return 'a score is 0 to 9';
203  if ('noul' in a && !within(a.noul, 0, 1)) return 'a noul is 0 to 1';
204  if ('confidence' in a && !within(a.confidence, 0, 1)) return 'confidence is 0 to 1';
205  if ('probabilities' in a) {
206    const p = a.probabilities;
207    if (!isObject(p)) return 'probabilities are { option: p }';
208    const entries = Object.entries(p);
209    if (entries.length > MAX_OPTIONS) return `at most ${MAX_OPTIONS} probabilities`;
210    if (!entries.every(([k, v]) => OPTION.test(k) && within(v, 0, 1))) return 'probabilities are { option: 0 to 1 }';
211  }
212  if ('sampled' in a && !within(a.sampled, Number.MIN_VALUE, 10)) return 'sampled is a temperature';
213  if ('top' in a && !(typeof a.top === 'string' && OPTION.test(a.top))) return 'top is an option name';
214  for (const flag of ['fallback', 'forced', 'absent']) if (flag in a && a[flag] !== true) return `${flag} is true or absent`;
215  return null;
216}
218function whyNotSegment(s: unknown): string | null {
219  if (!isObject(s)) return 'a segment is an object';
220  const extra = unexpected(s, ['status', 'ms', 'from', 'values']);
221  if (extra.length) return `unexpected segment fields: ${extra.join(', ')}`;
222  if (!['opening', 'answered', 'fallback'].includes(s.status as string)) return 'status is opening, answered or fallback';
223  if ('ms' in s && !count(s.ms, 600_000)) return 'ms is a whole number of milliseconds';
224  if ('from' in s && !count(s.from, 100_000)) return 'from is a cycle';
225  if (!isObject(s.values)) return 'values are { question: answer }';
226  const names = Object.keys(s.values);
227  if (!names.length || names.length > MAX_QUESTIONS) return `a segment has 1 to ${MAX_QUESTIONS} answers`;
228  for (const name of names) {
229    if (!NAME.test(name)) return `not a question name: ${name.slice(0, 64)}`;
230    const why = whyNotAnswer(s.values[name]);
231    if (why) return `${name}: ${why}`;
232  }
233  return null;
234}

null when body is a performance of a known song (a site song in known, or any listener song id: listening() then checks it is public); otherwise why not.

239export function whyNotPerformance(body: unknown, known: ReadonlySet<string>): string | null {
240  if (!isObject(body)) return 'body must be a JSON object';
241  const extra = unexpected(body, ['song', 'codeHash', 'model', 'every', 'ended', 'form', 'jevs']);
242  if (extra.length) return `unexpected fields: ${extra.join(', ')}`;
243  const { song, codeHash, model, every, ended, form, jevs } = body;
244  if (typeof song !== 'string' || !(LISTENER_SONG.test(song) || (SITE_SONG.test(song) && known.has(song)))) {
245    return 'song must be a song on this site';
246  }
247  if (typeof codeHash !== 'string' || !HASH.test(codeHash)) return 'codeHash is a SHA-256, in hex';
248  if (typeof model !== 'string' || !MODEL.test(model)) return 'model is a model name';
249  if (!(Number.isInteger(every) && within(every, 1, 1024))) return 'every is a whole number of cycles';
250  if (ended !== 'finished' && ended !== 'stopped') return 'ended is finished or stopped';
251  if (!Array.isArray(jevs) || !jevs.length || jevs.length > MAX_JEVS) return `jevs is a list of 1 to ${MAX_JEVS}`;
252  for (const [i, jev] of jevs.entries()) {
253    if (!isObject(jev) || unexpected(jev, ['segments']).length) return `jev ${i} is { segments }`;
254    const { segments } = jev;
255    if (!Array.isArray(segments) || !segments.length || segments.length > MAX_SEGMENTS) {
256      return `jev ${i} has 1 to ${MAX_SEGMENTS} segments`;
257    }
258    for (const [n, s] of segments.entries()) {
259      const why = whyNotSegment(s);
260      if (why) return `jev ${i}, segment ${n}: ${why}`;
261    }
262  }
263  if (form !== null) {
264    if (!isObject(form) || unexpected(form, ['jev', 'question']).length) return 'form is { jev, question } or null';
265    if (!count(form.jev, jevs.length - 1)) return 'form.jev is one of the jevs';
266    if (typeof form.question !== 'string' || !NAME.test(form.question)) return 'form.question is a question name';
267    const owner = (jevs[form.jev] as { segments: Segment[] }).segments;
268    const q = form.question;
269    if (!owner.every((s) => typeof s.values[q]?.choice === 'string')) return `every segment of the form's jev chooses ${q}`;
270  }
271  return null;
272}

A performance as validated, rebuilt from only the fields it may have, so nothing else in the body reaches the database.

276export function clean(p: Performance): Performance {
277  const answer = (a: Answer): Answer => {
278    const out: Answer = {};
279    for (const k of ['choice', 'score', 'noul', 'confidence', 'probabilities', 'sampled', 'top'] as const) {
280      if (a[k] !== undefined) (out as Record<string, unknown>)[k] = a[k];
281    }
282    for (const k of ['fallback', 'forced', 'absent'] as const) if (a[k]) out[k] = true;
283    return out;
284  };
285  return {
286    song: p.song,
287    codeHash: p.codeHash,
288    model: p.model,
289    every: p.every,
290    ended: p.ended,
291    form: p.form ? { jev: p.form.jev, question: p.form.question } : null,
292    jevs: p.jevs.map(({ segments }) => ({
293      segments: segments.map((s) => ({
294        status: s.status,
295        ...(s.ms !== undefined ? { ms: s.ms } : {}),
296        ...(s.from !== undefined ? { from: s.from } : {}),
297        values: Object.fromEntries(Object.entries(s.values).map(([k, a]) => [k, answer(a)])),
298      })),
299    })),
300  };
301}

16 random bytes, base64url: 22 characters.

304export function newId(): string {
305  const bytes = crypto.getRandomValues(new Uint8Array(16));
306  return btoa(String.fromCharCode(...bytes))
307    .replaceAll('+', '-')
308    .replaceAll('/', '_')
309    .replace(/=+$/, '');
310}
312const answer = (status: number, text: string, headers: Record<string, string> = {}) =>
313  new Response(text, { status, headers: { 'Cache-Control': 'no-store', ...headers } });
314
315async function readJson(request: Request, max: number): Promise<{ body: unknown } | Response> {
316  const raw = await request.arrayBuffer();
317  if (raw.byteLength > max) return answer(413, 'too large');
318  try {
319    return { body: JSON.parse(new TextDecoder().decode(raw)) };
320  } catch {
321    return answer(400, 'body must be JSON');
322  }
323}
324
325async function songs(env: Env): Promise<ReadonlySet<string> | Response> {
326  try {
327    return await knownSongs(env);
328  } catch (e) {
329    console.error({ event: 'jev.listening', error: (e as Error).message });
330    return answer(503, 'not being recorded right now');
331  }
332}

Who stores a performance, and whether a listener song may be played: the session's account, and the song's public view. Asked only when a performance is stored, so the reads need no database beyond the store.

337export type Listeners = {
338  player(request: Request): Promise<string | null>;
339  isPublicSong(id: string): Promise<boolean>;
340};
341export const d1Listeners = (env: Env): Listeners => ({
342  player: async (request) => (await signedIn(request, d1Accounts(env.DB)))?.id ?? null,
343  isPublicSong: async (id) => (await d1Content(env.DB).publicSong(id)) !== null,
344});
346export async function listening(
347  request: Request,
348  env: Env,
349  store: ListeningStore,
350  listeners: Listeners = d1Listeners(env),
351): Promise<Response> {
352  const url = new URL(request.url);
353  const { pathname } = url;
354  const reactions = pathname === REACTIONS_PATH;
355  const performanceId = pathname.startsWith(`${PERFORMANCES_PATH}/`) ? pathname.slice(PERFORMANCES_PATH.length + 1) : null;
356  const writes = request.method === 'POST';
357  const allowed = reactions || performanceId === null ? ['GET', 'POST'] : ['GET'];
358  if (!allowed.includes(request.method)) return answer(405, `${allowed.join(', ')} only`, { Allow: allowed.join(', ') });
359
360  const visitor = request.headers.get('CF-Connecting-IP') ?? 'local';
361  const recording = writes && !reactions;
362  const limit = recording ? env.RECORD_LIMIT : env.LISTEN_LIMIT;
363  const { success } = await limit.limit({ key: visitor });
364  if (!success) {
365    const period = (recording ? RECORD_LIMIT : LISTEN_LIMIT).period;
366    return answer(429, 'too many; try again in a minute', { 'Retry-After': String(period) });
367  }
368
369  if (reactions && !writes) {
370    const song = url.searchParams.get('song') ?? '';
371    if (!SONG_ID.test(song)) return answer(400, 'song must be a song id');
372    return Response.json({ song, sections: await store.tally(song) }, { headers: { 'Cache-Control': 'no-store' } });
373  }

a song's takes and moves change as it is played: kept a minute

376  const briefly = { 'Cache-Control': 'public, max-age=60' };
377  if (!reactions && !writes && performanceId === null) {
378    const song = url.searchParams.get('song') ?? '';
379    if (!SONG_ID.test(song)) return answer(400, 'song must be a song id');
380    const limit = pageNumber(url.searchParams.get('limit'), 20, 1, MAX_TAKES);
381    const offset = pageNumber(url.searchParams.get('offset'), 0, 0, MAX_OFFSET);
382    if (limit === null || offset === null) return answer(400, `limit is 1 to ${MAX_TAKES}, offset 0 to ${MAX_OFFSET}`);
383    return Response.json({ song, ...(await store.takes(song, limit, offset)) }, { headers: briefly });
384  }
385  if (performanceId === MOVES) {
386    const song = url.searchParams.get('song') ?? '';
387    if (!SONG_ID.test(song)) return answer(400, 'song must be a song id');
388    return Response.json(await store.moves(song), { headers: briefly });
389  }
391  if (performanceId !== null) {
392    if (!PERFORMANCE_ID.test(performanceId)) return answer(404, 'no such performance');
393    const p = await store.performance(performanceId);
394    if (!p) return answer(404, 'no such performance');
395    // a performance never changes once stored
396    return Response.json(p, { headers: { 'Cache-Control': 'public, max-age=86400, immutable' } });
397  }
398
399  const read = await readJson(request, reactions ? MAX_REACTION_BYTES : MAX_PERFORMANCE_BYTES);
400  if (read instanceof Response) return read;
401  const known = await songs(env);
402  if (known instanceof Response) return known;
403
404  if (reactions) {
405    const why = whyNotReaction(read.body, known);
406    if (why) return answer(400, why);
407    const { song, section, reaction } = read.body as Reaction;
408    await store.react({ song, section, reaction });
409    return new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } });
410  }
411
412  const why = whyNotPerformance(read.body, known);
413  if (why) return answer(400, why);
414  const listener = LISTENER_SONG.exec((read.body as Performance).song);
415  if (listener && !(await listeners.isPublicSong(listener[1]))) return answer(400, 'song must be a song on this site');
416  const id = newId();
417  const at = Date.now();
418  const player = await listeners.player(request);
419  await store.record(id, new Date(at).toISOString().slice(0, 10), clean(read.body as Performance), { at, player });
420  return Response.json({ id }, { status: 201, headers: { 'Cache-Control': 'no-store' } });
421}