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'.
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.
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.
"<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").
/jev/performances/moves: shorter than any id, so it names no performance
75const MOVES = 'moves';
a song's takes, a page at a time
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.
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).
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.
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.
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}