jevstrudel.git / worker / src / content-store.ts

Listeners' content in the site's D1 database (the DB binding; the schema is worker/migrations/0004_listener_content.sql): songs and their revisions, covers' records (the images are in R2), comments, pitches, and content_jobs, the work Jev still owes. content.ts validates everything before it arrives here; content-jobs.ts runs the jobs.

The jobs are made and removed by the schema's triggers as the items' screen and art change, so nothing here writes a job except to claim or reschedule it. A claim is one UPDATE … RETURNING: D1 runs writes one statement at a time on its primary, so two workers never claim the same job, and an expired lease is claimable again.

12import type { Subject, Verdict } from './screen';
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 };

A comment is on one of the site's songs or a listener's, by id.

24export type Target = { site: string } | { listener: string };
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 }[] };

What the author sees of one item: whether it is public, pending (and 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 };
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      };

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')`;
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});

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}
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,

── 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    },

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    },
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    },

A new cover for a song, replacing its old one; returns the old one's 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    },
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    },

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    },

── reading what is public ────────────────────── 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    },

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    },
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    },

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    },
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    },

Public pitches, newest or most voted first, each with its votes, whether viewer (an account id) voted for it, and the public listener songs that answer it (a site song says so in its SPEC.md, 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    },
309    pitchIsPublic,

An account's vote for a public pitch, cast (on) or taken back; the 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    },

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    },

── the author's own ──────────────────────────── 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    },

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    },

── the jobs ──────────────────────────────────── Claims up to limit due jobs (all users', or one user's), oldest due 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    },

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    },

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    },

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    },

The verdict, once: an item already screened is left as it is (a 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    },
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    },

── what anyone may read of everything (data.ts) ─ Every item, public or not: pending ones with why, held ones with Jev's verdict and confidence, each with its author's display name. As text only; data.ts never serves a held item's code or cover to be 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    },

A song and every revision whole (spec and code as text), whatever 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    },
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    },

Every pitch, with who voted for it and when (0005_pitch_votes.sql: votes are public, voters included), and the listener songs whose 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    },

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    },

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>;