1// Listeners' content in the site's D1 database (the DB binding; the schema 2// is worker/migrations/0004_listener_content.sql): songs and their 3// revisions, covers' records (the images are in R2), comments, pitches, and 4// content_jobs, the work Jev still owes. content.ts validates everything 5// before it arrives here; content-jobs.ts runs the jobs. 6// 7// The jobs are made and removed by the schema's triggers as the items' 8// `screen` and `art` change, so nothing here writes a job except to claim 9// or reschedule it. A claim is one UPDATE … RETURNING: D1 runs writes one 10// statement at a time on its primary, so two workers never claim the same 11// job, and an expired lease is claimable again. 12import type { Subject, Verdict } from './screen'; 13 14export type Kind = 'revision' | 'cover' | 'comment' | 'pitch'; 15export type Task = 'screen' | 'score'; 16export type Why = 'new' | 'budget' | 'budget-unavailable' | 'unreachable' | 'upstream' | 'unanswered'; 17export type Screen = 'pending' | Verdict; 18export type Job = { kind: Kind; item: string; task: Task; userId: string; dueAt: number; attempts: number; why: Why }; 19 20export type SongFields = { title: string; description: string; spec: string; code: string }; 21export type Art = { art: number; artRuns: number[]; criteria: Record<string, number>; model: string; method: string }; 22export type CoverRecord = { id: string; song: string; userId: string; contentType: string; bytes: number; alt: string }; 23// A comment is on one of the site's songs or a listener's, by id. 24export type Target = { site: string } | { listener: string }; 25 26export type PublicSong = { 27 id: string; 28 author: string; 29 // the author's account: its profile (data.ts's accounts/<id>) 30 authorId: string; 31 rev: number; 32 title: string; 33 description: string; 34 updatedAt: number; 35 art: Art | null; 36 cover: { id: string; alt: string } | null; 37 // the pitch it answers, its author's say (0005_pitch_votes.sql) 38 pitch: string | null; 39}; 40export type PublicSongView = PublicSong & { 41 spec: string; 42 code: string; 43 revisions: { rev: number; at: number; art: number | null }[]; 44}; 45export type PublicText = { id: string; author: string; authorId: string; body: string; at: number }; 46export type PitchSort = 'new' | 'votes'; 47export type PublicPitch = PublicText & { votes: number; voted: boolean; answers: { id: string; title: string }[] }; 48 49// What the author sees of one item: whether it is public, pending (and 50// why, and when Jev is asked again) or held (and Jev's verdict). 51export type Status = 52 | { status: 'public' } 53 | { status: 'pending'; why: Why; next: number; attempts: number } 54 | { status: 'held'; verdict: Exclude<Verdict, 'fine'>; confidence: number | null }; 55 56type Row = Record<string, unknown>; 57const art = (r: Row): PublicSong['art'] => 58 r.art === null || r.art === undefined 59 ? null 60 : { 61 art: r.art as number, 62 artRuns: JSON.parse(r.art_runs as string), 63 criteria: JSON.parse(r.criteria as string), 64 model: r.art_model as string, 65 method: r.art_method as string, 66 }; 67 68// The newest fine revision of each song: the song as the public sees it. 69const PUBLIC_REVISION = ` 70 SELECT r.* FROM song_revisions r 71 WHERE r.screen = 'fine' AND r.rev = (SELECT max(rev) FROM song_revisions WHERE song = r.song AND screen = 'fine')`; 72 73const publicSong = (r: Row): PublicSong => ({ 74 id: r.song as string, 75 author: r.author as string, 76 authorId: r.author_id as string, 77 rev: r.rev as number, 78 title: r.title as string, 79 description: r.description as string, 80 updatedAt: r.created_at as number, 81 art: art(r), 82 cover: r.cover_id ? { id: r.cover_id as string, alt: r.cover_alt as string } : null, 83 pitch: (r.pitch as string | null | undefined) ?? null, 84}); 85 86// Of an item's screen and its job (if any), what its author is told. 87export function statusOf(screen: Screen, confidence: number | null, job: Row | null): Status { 88 if (screen === 'fine') return { status: 'public' }; 89 if (screen === 'pending') { 90 // the schema's triggers make a pending item's job with it, and mine() 91 // reads both in one transaction, so a pending item without one is a 92 // broken invariant, not a state to show 93 if (!job) throw new Error('a pending item has no job'); 94 return { status: 'pending', why: job.why as Why, next: job.due_at as number, attempts: job.attempts as number }; 95 } 96 return { status: 'held', verdict: screen, confidence }; 97} 98 99export function d1Content(db: D1Database, now: () => number = Date.now) { 100 const TABLE: Record<Kind, string> = { 101 revision: 'song_revisions', 102 cover: 'covers', 103 comment: 'comments', 104 pitch: 'pitches', 105 }; 106 const pitchIsPublic = async (pitchId: string): Promise<boolean> => 107 (await db.prepare(`SELECT 1 AS yes FROM pitches WHERE id = ? AND screen = 'fine'`).bind(pitchId).first('yes')) === 1; 108 109 return { 110 // the store's clock, which the jobs also schedule by 111 now, 112 113 // ── writing content ───────────────────────────── 114 async createSong(userId: string, songId: string, revisionId: string, f: SongFields): Promise<void> { 115 const at = now(); 116 await db.batch([ 117 db.prepare('INSERT INTO listener_songs (id, user_id, created_at) VALUES (?, ?, ?)').bind(songId, userId, at), 118 db 119 .prepare( 120 `INSERT INTO song_revisions (id, song, rev, title, description, spec, code, created_at, screen) 121 VALUES (?, ?, 1, ?, ?, ?, ?, ?, 'pending')`, 122 ) 123 .bind(revisionId, songId, f.title, f.description, f.spec, f.code, at), 124 ]); 125 }, 126 127 // The next revision of a song, numbered after the last in the same statement. 128 async addRevision(songId: string, revisionId: string, f: SongFields): Promise<number> { 129 const row = await db 130 .prepare( 131 `INSERT INTO song_revisions (id, song, rev, title, description, spec, code, created_at, screen) 132 SELECT ?, ?, coalesce(max(rev), 0) + 1, ?, ?, ?, ?, ?, 'pending' FROM song_revisions WHERE song = ? 133 RETURNING rev`, 134 ) 135 .bind(revisionId, songId, f.title, f.description, f.spec, f.code, now(), songId) 136 .first<{ rev: number }>(); 137 return row!.rev; 138 }, 139 140 async songOwner(songId: string): Promise<string | null> { 141 return db.prepare('SELECT user_id FROM listener_songs WHERE id = ?').bind(songId).first<string>('user_id'); 142 }, 143 144 // A new cover for a song, replacing its old one; returns the old one's 145 // id, whose R2 object the caller deletes. 146 async putCover(c: CoverRecord): Promise<string | null> { 147 const old = await db.prepare('SELECT id FROM covers WHERE song = ?').bind(c.song).first<string>('id'); 148 await db.batch([ 149 db.prepare('DELETE FROM covers WHERE song = ?').bind(c.song), 150 db 151 .prepare( 152 `INSERT INTO covers (id, song, user_id, content_type, bytes, alt, created_at, screen) 153 VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')`, 154 ) 155 .bind(c.id, c.song, c.userId, c.contentType, c.bytes, c.alt, now()), 156 ]); 157 return old; 158 }, 159 160 async addComment(id: string, userId: string, target: Target, body: string): Promise<void> { 161 await db 162 .prepare( 163 `INSERT INTO comments (id, user_id, site_song, listener_song, body, created_at, screen) 164 VALUES (?, ?, ?, ?, ?, ?, 'pending')`, 165 ) 166 .bind(id, userId, 'site' in target ? target.site : null, 'listener' in target ? target.listener : null, body, now()) 167 .run(); 168 }, 169 170 async addPitch(id: string, userId: string, body: string): Promise<void> { 171 await db 172 .prepare(`INSERT INTO pitches (id, user_id, body, created_at, screen) VALUES (?, ?, ?, ?, 'pending')`) 173 .bind(id, userId, body, now()) 174 .run(); 175 }, 176 177 // How many of this user's items wait on a screening. 178 async pendingScreens(userId: string): Promise<number> { 179 const n = await db 180 .prepare(`SELECT count(*) AS n FROM content_jobs WHERE user_id = ? AND task = 'screen'`) 181 .bind(userId) 182 .first<number>('n'); 183 return n ?? 0; 184 }, 185 186 // ── reading what is public ────────────────────── 187 // Every public song, the most art first (unscored last), as the site's own are listed. 188 async publicSongs(limit = 200): Promise<PublicSong[]> { 189 const { results } = await db 190 .prepare( 191 `SELECT p.*, u.display_name AS author, u.id AS author_id, c.id AS cover_id, c.alt AS cover_alt, s.pitch AS pitch 192 FROM (${PUBLIC_REVISION}) p 193 JOIN listener_songs s ON s.id = p.song 194 JOIN users u ON u.id = s.user_id 195 LEFT JOIN covers c ON c.song = p.song AND c.screen = 'fine' 196 ORDER BY p.art IS NULL, p.art DESC, p.created_at DESC 197 LIMIT ?`, 198 ) 199 .bind(limit) 200 .all<Row>(); 201 return results.map(publicSong); 202 }, 203 204 // One account's public songs, newest first: its profile's. 205 async publicSongsBy(userId: string, limit = 100): Promise<PublicSong[]> { 206 const { results } = await db 207 .prepare( 208 `SELECT p.*, u.display_name AS author, u.id AS author_id, c.id AS cover_id, c.alt AS cover_alt 209 FROM (${PUBLIC_REVISION}) p 210 JOIN listener_songs s ON s.id = p.song 211 JOIN users u ON u.id = s.user_id 212 LEFT JOIN covers c ON c.song = p.song AND c.screen = 'fine' 213 WHERE s.user_id = ? 214 ORDER BY p.created_at DESC 215 LIMIT ?`, 216 ) 217 .bind(userId, limit) 218 .all<Row>(); 219 return results.map(publicSong); 220 }, 221 222 async publicSong(songId: string): Promise<PublicSongView | null> { 223 const r = await db 224 .prepare( 225 `SELECT p.*, u.display_name AS author, u.id AS author_id, c.id AS cover_id, c.alt AS cover_alt, s.pitch AS pitch 226 FROM (${PUBLIC_REVISION}) p 227 JOIN listener_songs s ON s.id = p.song 228 JOIN users u ON u.id = s.user_id 229 LEFT JOIN covers c ON c.song = p.song AND c.screen = 'fine' 230 WHERE p.song = ?`, 231 ) 232 .bind(songId) 233 .first<Row>(); 234 if (!r) return null; 235 const { results } = await db 236 .prepare(`SELECT rev, created_at AS at, art FROM song_revisions WHERE song = ? AND screen = 'fine' ORDER BY rev`) 237 .bind(songId) 238 .all<{ rev: number; at: number; art: number | null }>(); 239 return { ...publicSong(r), spec: r.spec as string, code: r.code as string, revisions: results }; 240 }, 241 242 // A cover as served: only a fine one whose song is public. 243 async publicCover(coverId: string): Promise<{ contentType: string; bytes: number } | null> { 244 return db 245 .prepare( 246 `SELECT c.content_type AS contentType, c.bytes FROM covers c 247 WHERE c.id = ? AND c.screen = 'fine' 248 AND EXISTS (SELECT 1 FROM song_revisions r WHERE r.song = c.song AND r.screen = 'fine')`, 249 ) 250 .bind(coverId) 251 .first<{ contentType: string; bytes: number }>(); 252 }, 253 254 async publicComments(target: Target, limit = 200): Promise<PublicText[]> { 255 const [column, value] = 'site' in target ? ['site_song', target.site] : ['listener_song', target.listener]; 256 const { results } = await db 257 .prepare( 258 `SELECT c.id, u.display_name AS author, u.id AS authorId, c.body, c.created_at AS at FROM comments c 259 JOIN users u ON u.id = c.user_id 260 WHERE c.${column} = ? AND c.screen = 'fine' ORDER BY c.created_at LIMIT ?`, 261 ) 262 .bind(value, limit) 263 .all<PublicText>(); 264 return results; 265 }, 266 267 // Public pitches, newest or most voted first, each with its votes, 268 // whether `viewer` (an account id) voted for it, and the public 269 // listener songs that answer it (a site song says so in its SPEC.md, 270 // which the page reads). 271 async publicPitches( 272 limit = 100, 273 { sort = 'new', viewer = null }: { sort?: PitchSort; viewer?: string | null } = {}, 274 ): Promise<PublicPitch[]> { 275 const order = sort === 'votes' ? 'votes DESC, p.created_at DESC' : 'p.created_at DESC'; 276 const [pitches, answers] = (await db.batch([ 277 db 278 .prepare( 279 `SELECT p.id, u.display_name AS author, u.id AS authorId, p.body, p.created_at AS at, 280 (SELECT count(*) FROM pitch_votes v WHERE v.pitch = p.id) AS votes, 281 EXISTS (SELECT 1 FROM pitch_votes v WHERE v.pitch = p.id AND v.user_id = ?) AS voted 282 FROM pitches p JOIN users u ON u.id = p.user_id 283 WHERE p.screen = 'fine' ORDER BY ${order} LIMIT ?`, 284 ) 285 .bind(viewer ?? '', limit), 286 db.prepare( 287 `SELECT s.pitch, p.song AS id, p.title FROM (${PUBLIC_REVISION}) p 288 JOIN listener_songs s ON s.id = p.song WHERE s.pitch IS NOT NULL ORDER BY p.created_at`, 289 ), 290 ])) as D1Result<Row>[]; 291 const answering = new Map<string, { id: string; title: string }[]>(); 292 for (const a of answers.results) { 293 const list = answering.get(a.pitch as string) ?? []; 294 list.push({ id: a.id as string, title: a.title as string }); 295 answering.set(a.pitch as string, list); 296 } 297 return pitches.results.map((p) => ({ 298 id: p.id as string, 299 author: p.author as string, 300 authorId: p.authorId as string, 301 body: p.body as string, 302 at: p.at as number, 303 votes: p.votes as number, 304 voted: Boolean(p.voted), 305 answers: answering.get(p.id as string) ?? [], 306 })); 307 }, 308 309 pitchIsPublic, 310 311 // An account's vote for a public pitch, cast (`on`) or taken back; the 312 // pitch's votes after, or null when it is not public. 313 async votePitch(pitchId: string, userId: string, on: boolean): Promise<{ votes: number; voted: boolean } | null> { 314 if (!(await pitchIsPublic(pitchId))) return null; 315 const [, count] = (await db.batch([ 316 on 317 ? db.prepare('INSERT OR IGNORE INTO pitch_votes (pitch, user_id, created_at) VALUES (?, ?, ?)').bind(pitchId, userId, now()) 318 : db.prepare('DELETE FROM pitch_votes WHERE pitch = ? AND user_id = ?').bind(pitchId, userId), 319 db.prepare('SELECT count(*) AS n FROM pitch_votes WHERE pitch = ?').bind(pitchId), 320 ])) as D1Result<Row>[]; 321 return { votes: (count.results[0]?.n as number) ?? 0, voted: on }; 322 }, 323 324 // The pitch a listener song answers (null for none); its author's call. 325 async setSongPitch(songId: string, pitchId: string | null): Promise<void> { 326 await db.prepare('UPDATE listener_songs SET pitch = ? WHERE id = ?').bind(pitchId, songId).run(); 327 }, 328 329 // ── the author's own ──────────────────────────── 330 // Everything this user wrote, newest first, each with its status. 331 async mine(userId: string) { 332 // one batch, one transaction: the items and their jobs as of one moment 333 const [jobRows, revisions, covers, comments, pitches] = (await db.batch([ 334 db.prepare('SELECT kind, item, task, due_at, attempts, why FROM content_jobs WHERE user_id = ?').bind(userId), 335 db 336 .prepare( 337 `SELECT r.id, r.song, r.rev, r.title, r.created_at, r.screen, r.screen_confidence, r.art 338 FROM song_revisions r JOIN listener_songs s ON s.id = r.song 339 WHERE s.user_id = ? ORDER BY r.created_at DESC LIMIT 200`, 340 ) 341 .bind(userId), 342 db 343 .prepare( 344 'SELECT id, song, alt, created_at, screen, screen_confidence FROM covers WHERE user_id = ? ORDER BY created_at DESC', 345 ) 346 .bind(userId), 347 db 348 .prepare( 349 `SELECT id, site_song, listener_song, body, created_at, screen, screen_confidence FROM comments 350 WHERE user_id = ? ORDER BY created_at DESC LIMIT 200`, 351 ) 352 .bind(userId), 353 db 354 .prepare( 355 'SELECT id, body, created_at, screen, screen_confidence FROM pitches WHERE user_id = ? ORDER BY created_at DESC LIMIT 200', 356 ) 357 .bind(userId), 358 ])) as D1Result<Row>[]; 359 const jobs = new Map<string, Row>(); 360 for (const j of jobRows.results) jobs.set(`${j.kind}/${j.item}/${j.task}`, j); 361 const status = (kind: Kind, r: Row) => 362 statusOf(r.screen as Screen, (r.screen_confidence as number | null) ?? null, jobs.get(`${kind}/${r.id}/screen`) ?? null); 363 364 return { 365 revisions: revisions.results.map((r) => { 366 const score = jobs.get(`revision/${r.id}/score`); 367 return { 368 song: r.song as string, 369 rev: r.rev as number, 370 title: r.title as string, 371 at: r.created_at as number, 372 ...status('revision', r), 373 art: (r.art as number | null) ?? null, 374 // a fine revision not scored yet says why, as a pending screening does 375 ...(score 376 ? { score: { why: score.why as Why, next: score.due_at as number, attempts: score.attempts as number } } 377 : {}), 378 }; 379 }), 380 covers: covers.results.map((r) => ({ 381 id: r.id as string, 382 song: r.song as string, 383 alt: r.alt as string, 384 at: r.created_at as number, 385 ...status('cover', r), 386 })), 387 comments: comments.results.map((r) => ({ 388 id: r.id as string, 389 song: (r.site_song as string | null) ?? `listener:${r.listener_song}`, 390 body: r.body as string, 391 at: r.created_at as number, 392 ...status('comment', r), 393 })), 394 pitches: pitches.results.map((r) => ({ 395 id: r.id as string, 396 body: r.body as string, 397 at: r.created_at as number, 398 ...status('pitch', r), 399 })), 400 }; 401 }, 402 403 // One item's status, for the answer to the write that made it. 404 async itemStatus(kind: Kind, item: string): Promise<Status | null> { 405 const [rows, jobRows] = (await db.batch([ 406 db.prepare(`SELECT screen, screen_confidence FROM ${TABLE[kind]} WHERE id = ?`).bind(item), 407 db.prepare(`SELECT due_at, attempts, why FROM content_jobs WHERE kind = ? AND item = ? AND task = 'screen'`).bind(kind, item), 408 ])) as D1Result<Row>[]; 409 const r = rows.results[0]; 410 return r ? statusOf(r.screen as Screen, (r.screen_confidence as number | null) ?? null, jobRows.results[0] ?? null) : null; 411 }, 412 413 // ── the jobs ──────────────────────────────────── 414 // Claims up to `limit` due jobs (all users', or one user's), oldest due 415 // first, for `leaseMs`. 416 async claimDue(limit: number, leaseMs: number, userId: string | null = null): Promise<Job[]> { 417 const t = now(); 418 const { results } = await db 419 .prepare( 420 `UPDATE content_jobs SET lease_until = ?1 421 WHERE rowid IN ( 422 SELECT rowid FROM content_jobs 423 WHERE due_at <= ?2 AND (lease_until IS NULL OR lease_until <= ?2) AND (?3 IS NULL OR user_id = ?3) 424 ORDER BY due_at LIMIT ?4) 425 RETURNING kind, item, task, user_id, due_at, attempts, why`, 426 ) 427 .bind(t + leaseMs, t, userId, limit) 428 .all<Row>(); 429 return results.map((r) => ({ 430 kind: r.kind as Kind, 431 item: r.item as string, 432 task: r.task as Task, 433 userId: r.user_id as string, 434 dueAt: r.due_at as number, 435 attempts: r.attempts as number, 436 why: r.why as Why, 437 })); 438 }, 439 440 // Not done: asked again at `dueAt`, saying why. 441 async reschedule(job: Job, dueAt: number, why: Why): Promise<void> { 442 await db 443 .prepare( 444 `UPDATE content_jobs SET due_at = ?, why = ?, attempts = attempts + 1, lease_until = NULL 445 WHERE kind = ? AND item = ? AND task = ?`, 446 ) 447 .bind(dueAt, why, job.kind, job.item, job.task) 448 .run(); 449 }, 450 451 // What the screening of an item is asked about, or null if it is gone. 452 async subject(kind: Kind, item: string): Promise<Subject | null> { 453 switch (kind) { 454 case 'revision': { 455 const r = await db 456 .prepare('SELECT title, description, spec, code, rev FROM song_revisions WHERE id = ?') 457 .bind(item) 458 .first<{ title: string; description: string; spec: string; code: string; rev: number }>(); 459 return r && { kind, ...r }; 460 } 461 case 'cover': { 462 const r = await db 463 .prepare( 464 `SELECT c.alt, (SELECT title FROM song_revisions WHERE song = c.song ORDER BY rev DESC LIMIT 1) AS songTitle 465 FROM covers c WHERE c.id = ?`, 466 ) 467 .bind(item) 468 .first<{ alt: string; songTitle: string | null }>(); 469 return r && { kind, alt: r.alt, songTitle: r.songTitle }; 470 } 471 case 'comment': { 472 const r = await db 473 .prepare( 474 `SELECT c.body, coalesce(c.site_song, 475 (SELECT title FROM song_revisions WHERE song = c.listener_song AND screen = 'fine' ORDER BY rev DESC LIMIT 1), 476 'a listener''s song') AS song 477 FROM comments c WHERE c.id = ?`, 478 ) 479 .bind(item) 480 .first<{ body: string; song: string }>(); 481 return r && { kind, ...r }; 482 } 483 case 'pitch': { 484 const r = await db.prepare('SELECT body FROM pitches WHERE id = ?').bind(item).first<{ body: string }>(); 485 return r && { kind, body: r.body }; 486 } 487 } 488 }, 489 490 // A revision as the critic reads it, or null if it is gone. 491 async toScore(item: string): Promise<{ title: string; spec: string; code: string } | null> { 492 return db 493 .prepare(`SELECT title, spec, code FROM song_revisions WHERE id = ? AND screen = 'fine'`) 494 .bind(item) 495 .first<{ title: string; spec: string; code: string }>(); 496 }, 497 498 // The verdict, once: an item already screened is left as it is (a 499 // second worker whose lease had expired lost the race). 500 async recordScreen(kind: Kind, item: string, verdict: Verdict, confidence: number | null): Promise<boolean> { 501 const { meta } = await db 502 .prepare( 503 `UPDATE ${TABLE[kind]} SET screen = ?, screen_confidence = ?, screened_at = ? WHERE id = ? AND screen = 'pending'`, 504 ) 505 .bind(verdict, confidence, now(), item) 506 .run(); 507 return meta.changes > 0; 508 }, 509 510 async recordScore(item: string, a: Art): Promise<boolean> { 511 const { meta } = await db 512 .prepare( 513 `UPDATE song_revisions SET art = ?, art_runs = ?, criteria = ?, art_model = ?, art_method = ?, scored_at = ? 514 WHERE id = ? AND screen = 'fine' AND art IS NULL`, 515 ) 516 .bind(a.art, JSON.stringify(a.artRuns), JSON.stringify(a.criteria), a.model, a.method, now(), item) 517 .run(); 518 return meta.changes > 0; 519 }, 520 521 // ── what anyone may read of everything (data.ts) ─ 522 // Every item, public or not: pending ones with why, held ones with 523 // Jev's verdict and confidence, each with its author's display name. 524 // As text only; data.ts never serves a held item's code or cover to be 525 // played or shown as an image. 526 async songsPage(limit: number, offset: number) { 527 const [total, page] = (await db.batch([ 528 db.prepare('SELECT count(*) AS n FROM listener_songs'), 529 db 530 .prepare( 531 `SELECT s.id, u.id AS author_id, u.display_name AS author, s.created_at, 532 r.rev, r.title, r.screen, r.art, 533 (SELECT count(*) FROM song_revisions x WHERE x.song = s.id) AS revisions, 534 EXISTS (SELECT 1 FROM song_revisions x WHERE x.song = s.id AND x.screen = 'fine') AS public 535 FROM listener_songs s JOIN users u ON u.id = s.user_id 536 LEFT JOIN song_revisions r ON r.song = s.id AND r.rev = (SELECT max(rev) FROM song_revisions WHERE song = s.id) 537 ORDER BY s.created_at DESC, s.id LIMIT ? OFFSET ?`, 538 ) 539 .bind(limit, offset), 540 ])) as D1Result<Row>[]; 541 return { 542 total: (total.results[0]?.n as number) ?? 0, 543 songs: page.results.map((r) => ({ 544 id: r.id as string, 545 author: { id: r.author_id as string, name: r.author as string }, 546 created: r.created_at as number, 547 newest: { rev: r.rev as number, title: r.title as string, screen: r.screen as Screen, art: (r.art as number | null) ?? null }, 548 revisions: r.revisions as number, 549 public: Boolean(r.public), 550 })), 551 }; 552 }, 553 554 // A song and every revision whole (spec and code as text), whatever 555 // Jev's verdict, with its cover's record. 556 async songRecord(songId: string) { 557 const [songs, revisions, covers] = (await db.batch([ 558 db 559 .prepare( 560 `SELECT s.id, s.created_at, u.id AS author_id, u.display_name AS author FROM listener_songs s 561 JOIN users u ON u.id = s.user_id WHERE s.id = ?`, 562 ) 563 .bind(songId), 564 db 565 .prepare( 566 `SELECT rev, title, description, spec, code, created_at, screen, screen_confidence, screened_at, 567 art, criteria, art_model, scored_at FROM song_revisions WHERE song = ? ORDER BY rev DESC`, 568 ) 569 .bind(songId), 570 db 571 .prepare('SELECT id, content_type, bytes, alt, created_at, screen, screen_confidence FROM covers WHERE song = ?') 572 .bind(songId), 573 ])) as D1Result<Row>[]; 574 const s = songs.results[0]; 575 if (!s) return null; 576 const c = covers.results[0]; 577 return { 578 id: s.id as string, 579 author: { id: s.author_id as string, name: s.author as string }, 580 created: s.created_at as number, 581 revisions: revisions.results.map((r) => ({ 582 rev: r.rev as number, 583 title: r.title as string, 584 description: r.description as string, 585 spec: r.spec as string, 586 code: r.code as string, 587 created: r.created_at as number, 588 screen: r.screen as Screen, 589 confidence: (r.screen_confidence as number | null) ?? null, 590 screened: (r.screened_at as number | null) ?? null, 591 art: (r.art as number | null) ?? null, 592 criteria: r.criteria ? (JSON.parse(r.criteria as string) as Record<string, number>) : null, 593 artModel: (r.art_model as string | null) ?? null, 594 scored: (r.scored_at as number | null) ?? null, 595 })), 596 cover: c 597 ? { 598 id: c.id as string, 599 contentType: c.content_type as string, 600 bytes: c.bytes as number, 601 alt: c.alt as string, 602 created: c.created_at as number, 603 screen: c.screen as Screen, 604 confidence: (c.screen_confidence as number | null) ?? null, 605 } 606 : null, 607 }; 608 }, 609 610 async coversPage(limit: number, offset: number) { 611 const [total, page] = (await db.batch([ 612 db.prepare('SELECT count(*) AS n, coalesce(sum(bytes), 0) AS bytes FROM covers'), 613 db 614 .prepare( 615 `SELECT c.id, c.song, c.content_type, c.bytes, c.alt, c.created_at, c.screen, c.screen_confidence, 616 u.id AS author_id, u.display_name AS author, 617 EXISTS (SELECT 1 FROM song_revisions r WHERE r.song = c.song AND r.screen = 'fine') AS song_public 618 FROM covers c JOIN users u ON u.id = c.user_id 619 ORDER BY c.created_at DESC, c.id LIMIT ? OFFSET ?`, 620 ) 621 .bind(limit, offset), 622 ])) as D1Result<Row>[]; 623 return { 624 total: (total.results[0]?.n as number) ?? 0, 625 bytes: (total.results[0]?.bytes as number) ?? 0, 626 covers: page.results.map((c) => ({ 627 id: c.id as string, 628 song: c.song as string, 629 author: { id: c.author_id as string, name: c.author as string }, 630 contentType: c.content_type as string, 631 bytes: c.bytes as number, 632 alt: c.alt as string, 633 created: c.created_at as number, 634 screen: c.screen as Screen, 635 confidence: (c.screen_confidence as number | null) ?? null, 636 // what the site serves at /jev/listeners/covers/<id> (publicCover) 637 served: c.screen === 'fine' && Boolean(c.song_public), 638 })), 639 }; 640 }, 641 642 async commentsPage(limit: number, offset: number) { 643 const [total, page] = (await db.batch([ 644 db.prepare('SELECT count(*) AS n FROM comments'), 645 db 646 .prepare( 647 `SELECT c.id, c.site_song, c.listener_song, c.body, c.created_at, c.screen, c.screen_confidence, 648 u.id AS author_id, u.display_name AS author 649 FROM comments c JOIN users u ON u.id = c.user_id 650 ORDER BY c.created_at DESC, c.id LIMIT ? OFFSET ?`, 651 ) 652 .bind(limit, offset), 653 ])) as D1Result<Row>[]; 654 return { 655 total: (total.results[0]?.n as number) ?? 0, 656 comments: page.results.map((c) => ({ 657 id: c.id as string, 658 song: (c.site_song as string | null) ?? `listener:${c.listener_song}`, 659 author: { id: c.author_id as string, name: c.author as string }, 660 body: c.body as string, 661 created: c.created_at as number, 662 screen: c.screen as Screen, 663 confidence: (c.screen_confidence as number | null) ?? null, 664 })), 665 }; 666 }, 667 668 // Every pitch, with who voted for it and when (0005_pitch_votes.sql: 669 // votes are public, voters included), and the listener songs whose 670 // authors say they answer it. 671 async pitchesPage(limit: number, offset: number) { 672 const onPage = `SELECT id FROM pitches ORDER BY created_at DESC, id LIMIT ? OFFSET ?`; 673 const [total, page, votes, answers] = (await db.batch([ 674 db.prepare('SELECT count(*) AS n FROM pitches'), 675 db 676 .prepare( 677 `SELECT p.id, p.body, p.created_at, p.screen, p.screen_confidence, u.id AS author_id, u.display_name AS author 678 FROM pitches p JOIN users u ON u.id = p.user_id 679 ORDER BY p.created_at DESC, p.id LIMIT ? OFFSET ?`, 680 ) 681 .bind(limit, offset), 682 db 683 .prepare( 684 `SELECT v.pitch, v.created_at, u.id AS voter_id, u.display_name AS voter 685 FROM pitch_votes v JOIN users u ON u.id = v.user_id 686 WHERE v.pitch IN (${onPage}) ORDER BY v.created_at`, 687 ) 688 .bind(limit, offset), 689 db 690 .prepare(`SELECT s.id, s.pitch FROM listener_songs s WHERE s.pitch IN (${onPage}) ORDER BY s.created_at`) 691 .bind(limit, offset), 692 ])) as D1Result<Row>[]; 693 const votersOf = new Map<string, { id: string; name: string; at: number }[]>(); 694 for (const v of votes.results) { 695 const list = votersOf.get(v.pitch as string) ?? []; 696 list.push({ id: v.voter_id as string, name: v.voter as string, at: v.created_at as number }); 697 votersOf.set(v.pitch as string, list); 698 } 699 const songsOf = new Map<string, string[]>(); 700 for (const a of answers.results) songsOf.set(a.pitch as string, [...(songsOf.get(a.pitch as string) ?? []), a.id as string]); 701 return { 702 total: (total.results[0]?.n as number) ?? 0, 703 pitches: page.results.map((p) => ({ 704 id: p.id as string, 705 author: { id: p.author_id as string, name: p.author as string }, 706 body: p.body as string, 707 created: p.created_at as number, 708 screen: p.screen as Screen, 709 confidence: (p.screen_confidence as number | null) ?? null, 710 votes: votersOf.get(p.id as string) ?? [], 711 answeredBy: songsOf.get(p.id as string) ?? [], 712 })), 713 }; 714 }, 715 716 // The work Jev owes, oldest due first, with whose budget pays. 717 async jobsPage(limit: number, offset: number) { 718 const [total, page] = (await db.batch([ 719 db.prepare('SELECT count(*) AS n FROM content_jobs'), 720 db 721 .prepare( 722 `SELECT j.kind, j.item, j.task, j.due_at, j.attempts, j.why, j.lease_until, u.id AS author_id, u.display_name AS author 723 FROM content_jobs j JOIN users u ON u.id = j.user_id 724 ORDER BY j.due_at, j.kind, j.item LIMIT ? OFFSET ?`, 725 ) 726 .bind(limit, offset), 727 ])) as D1Result<Row>[]; 728 const t = now(); 729 return { 730 total: (total.results[0]?.n as number) ?? 0, 731 jobs: page.results.map((j) => ({ 732 kind: j.kind as Kind, 733 item: j.item as string, 734 task: j.task as Task, 735 author: { id: j.author_id as string, name: j.author as string }, 736 due: j.due_at as number, 737 attempts: j.attempts as number, 738 why: j.why as Why, 739 running: j.lease_until !== null && (j.lease_until as number) > t, 740 })), 741 }; 742 }, 743 744 // How many of each kind there are, by Jev's verdict. 745 async contentTotals() { 746 const byScreen = (table: string) => db.prepare(`SELECT screen, count(*) AS n FROM ${table} GROUP BY screen`); 747 const [songs, revisions, covers, comments, pitches, jobs, pitchVotes] = (await db.batch([ 748 db.prepare('SELECT count(*) AS n FROM listener_songs'), 749 byScreen('song_revisions'), 750 byScreen('covers'), 751 byScreen('comments'), 752 byScreen('pitches'), 753 db.prepare('SELECT task, why, count(*) AS n FROM content_jobs GROUP BY task, why ORDER BY task, why'), 754 db.prepare('SELECT count(*) AS n FROM pitch_votes'), 755 ])) as D1Result<Row>[]; 756 const screens = (r: D1Result<Row>) => 757 Object.fromEntries(r.results.map((x) => [x.screen as string, x.n as number])) as Partial<Record<Screen, number>>; 758 return { 759 songs: (songs.results[0]?.n as number) ?? 0, 760 revisions: screens(revisions), 761 covers: screens(covers), 762 comments: screens(comments), 763 pitches: screens(pitches), 764 pitchVotes: (pitchVotes.results[0]?.n as number) ?? 0, 765 jobs: jobs.results.map((j) => ({ task: j.task as Task, why: j.why as Why, n: j.n as number })), 766 }; 767 }, 768 }; 769} 770export type ContentStore = ReturnType<typeof d1Content>;