Skip to the content

Capsid menu

app/db/posts.ts

633 lines and 54 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of posts.ts
FunctionLinesComplexityCoveredCRAPMutation
publiclyVisibleSlugs33 to 432No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:42>42 to 421No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
nextScheduledPublishAt49 to 632No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
newestFirstOrder75 to 801No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listingSpanSelect103 to 1171No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
spanOf120 to 1264No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
carriesTag129 to 1381No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
getBlogTag141 to 1512No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
groupValues154 to 1623No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
tagsForPosts164 to 1752No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:174>174 to 1741No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:174>174 to 1741No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listBlogPosts177 to 27311No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:213>213 to 2451No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:265>265 to 2651No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listHomeStartHere276 to 3123No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:285>285 to 3041No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listBlogYears314 to 3241No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listSeriesParts326 to 3321No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listBlogSeries334 to 3411No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
getBlogSeries344 to 3503No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:346>346 to 3462No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listSeriesPosts353 to 3844No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:376>376 to 3761No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:378>378 to 3782No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listBlogTags386 to 3951No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listPostsWithImages398 to 4031No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
getBlogPost405 to 4183No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
previewBlogPost426 to 4281No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
withNeighbours430 to 4786No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
beyond443 to 4561No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
getDraftPostForPreview481 to 5013No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
adminNavCounts504 to 5113No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listAllPostsForAdmin513 to 5261No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listPostCorpusForRelated529 to 5451No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:543>543 to 5431No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:543>543 to 5431No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:544>544 to 5442No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listPostLinkCorpus548 to 5591No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listPostDerivedLists562 to 5671No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listPostSourcesForCitations570 to 5801No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listPostsForOperator583 to 6031No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:601>601 to 6011No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:601>601 to 6011No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:602>602 to 6022No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
getAdminPostRow605 to 6162No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listAllPostTagsForAdmin619 to 6271No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
feedScope649 to 6564No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
withFeedTags659 to 6621No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:660>660 to 6601No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:661>661 to 6612No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listBlogPostsFullText665 to 6742No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listBlogPostsRendered677 to 6862No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
getBlogPostMarkdown688 to 6952No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code

Imports

It imports app/db/client.ts, app/db/schema.ts, app/lib/blog-listing.mjs, app/lib/search/visibility.mjs, app/lib/series-path.mjs, app/lib/timing.ts. Imported by app/db/index.ts, app/lib/context.ts, app/lib/site-urls.server.ts.

Source

// Posts, tags, series and the feeds, as read by the site, the admin and the operator.
import {
  and,
  asc,
  count,
  desc,
  eq,
  gt,
  inArray,
  isNotNull,
  lt,
  max,
  ne,
  or,
  sql,
  type SQL,
} from "drizzle-orm";
import {
  HOME_CARDS,
  HOME_PICKS,
  POSTS_PER_PAGE,
  pageCount,
  picksFirst,
  startHere,
} from "../lib/blog-listing.mjs";
import { PUBLISHED_STATUS } from "../lib/search/visibility.mjs";
import { seriesSlug } from "../lib/series-path.mjs";
import { timed, type Timings } from "../lib/timing";
import { media, postTags, posts, tags } from "./schema";
import { getDb, type DB, publiclyVisible, isBlogPost, notTrashed } from "./client";

/** Ask's cache replay re-checks visibility here because KV invalidation lags. */
export async function publiclyVisibleSlugs(
  env: Env,
  slugs: string[],
): Promise<Set<string>> {
  if (slugs.length === 0) return new Set();
  const rows = await getDb(env)
    .select({ slug: posts.slug })
    .from(posts)
    .where(and(inArray(posts.slug, slugs), publiclyVisible()));
  return new Set(rows.map((r) => r.slug));
}

/**
 * The earliest publish_at still ahead on a published post, or null. Nothing purges when one passes,
 * so the pages that list posts cap their edge lifetime at it.
 */
export async function nextScheduledPublishAt(env: Env): Promise<Date | null> {
  const [row] = await getDb(env)
    .select({ publishAt: posts.publishAt })
    .from(posts)
    .where(
      and(
        eq(posts.kind, "post"),
        eq(posts.status, PUBLISHED_STATUS),
        gt(posts.publishAt, new Date()),
      ),
    )
    .orderBy(asc(posts.publishAt))
    .limit(1);
  return row?.publishAt ?? null;
}

/**
 * The listing order, newest first: publish instant, then a series together with its latest part first, then
 * the later row. app/lib/post-order.mjs states the rule and applies it to the header's build-time list; this is
 * the same rule in SQL, so the /writing listing, the home page, the feeds and the header cannot disagree.
 *
 * `groupKey` is the largest id among the published posts sharing this row's instant and series, or the row's
 * own id when it has no series. It is what keeps a series together and sorts it among its day's other posts.
 */
const groupKey = sql<number>`COALESCE(CASE WHEN "posts"."series" IS NULL THEN NULL ELSE (SELECT MAX(g.id) FROM posts g WHERE g.kind = 'post' AND g.status = ${PUBLISHED_STATUS} AND g.publish_at = "posts"."publish_at" AND g.series = "posts"."series") END, "posts"."id")`;
const partKey = sql<number>`COALESCE(${posts.part}, 0)`;
const newestFirstOrder = (direction: typeof desc | typeof asc = desc) => [
  direction(posts.publishAt),
  direction(groupKey),
  direction(partKey),
  direction(posts.id),
];

/** Columns the index and feed need. Deliberately excludes body and html. */
const postCard = {
  slug: posts.slug,
  title: posts.title,
  description: posts.description,
  publishAt: posts.publishAt,
  updatedAt: posts.updatedAt,
  coverImage: posts.coverImage,
  coverAlt: posts.coverAlt,
  readingTimeMinutes: posts.readingTimeMinutes,
  featured: posts.featured,
  series: posts.series,
  part: posts.part,
};


/**
 * Composes `isBlogPost()` itself and takes only the extra clauses, so no caller can pass a `where`
 * missing the visibility predicate. Years come from SQLite, as in `listBlogYears`, so two
 * conversions cannot disagree across a new year in another time zone.
 */
function listingSpanSelect(db: DB, ...narrowing: (SQL | undefined)[]) {
  return db
    .select({
      total: count(),
      firstYear: sql<string | null>`strftime('%Y', min(${posts.publishAt}), 'unixepoch')`.as(
        "first_year",
      ),
      lastYear: sql<string | null>`strftime('%Y', max(${posts.publishAt}), 'unixepoch')`.as(
        "last_year",
      ),
      minutes: sql<number | null>`sum(${posts.readingTimeMinutes})`.as("minutes"),
    })
    .from(posts)
    .where(and(isBlogPost(), ...narrowing));
}

/** A NULL is not a zero: an empty list has no span, and the row omits itself. */
function spanOf(row: { firstYear: string | null; lastYear: string | null; minutes: number | null } | undefined) {
  return {
    firstYear: row?.firstYear ?? null,
    lastYear: row?.lastYear ?? null,
    minutes: row?.minutes ?? null,
  };
}

/** A subquery, not a join: a join would multiply rows. */
function carriesTag(db: DB, tagSlug: string) {
  return inArray(
    posts.id,
    db
      .select({ id: postTags.postId })
      .from(postTags)
      .innerJoin(tags, eq(tags.id, postTags.tagId))
      .where(eq(tags.slug, tagSlug)),
  );
}

/** One tag by slug, or null when no publicly visible post carries it (the archive 404s). */
export async function getBlogTag(env: Env, tagSlug: string) {
  const rows = await getDb(env)
    .select({ slug: tags.slug, name: tags.name, total: count(posts.id) })
    .from(tags)
    .innerJoin(postTags, eq(postTags.tagId, tags.id))
    .innerJoin(posts, eq(posts.id, postTags.postId))
    .where(and(eq(tags.slug, tagSlug), isBlogPost()))
    .groupBy(tags.slug, tags.name)
    .limit(1);
  return rows[0] ?? null;
}

/** Groups each row's value under its key, keeping row order within a group. */
function groupValues<R, K, V>(rows: Iterable<R>, key: (row: R) => K, value: (row: R) => V) {
  const out = new Map<K, V[]>();
  for (const row of rows) {
    const list = out.get(key(row));
    if (list) list.push(value(row));
    else out.set(key(row), [value(row)]);
  }
  return out;
}

async function tagsForPosts(db: DB, postIds: number[]) {
  if (postIds.length === 0) return new Map<number, string[]>();

  const rows = await db
    .select({ postId: postTags.postId, name: tags.name, slug: tags.slug })
    .from(postTags)
    .innerJoin(tags, eq(tags.id, postTags.tagId))
    .where(inArray(postTags.postId, postIds))
    .orderBy(asc(tags.slug));

  return groupValues(rows, (row) => row.postId, (row) => row.slug);
}

export async function listBlogPosts(
  env: Env,
  options: {
    tag?: string | null;
    year?: string | null;
    page?: number;
    perPage?: number;
    timings?: Timings;
  } = {},
) {
  const db = getDb(env);
  const perPage = options.perPage ?? POSTS_PER_PAGE;
  const page = Math.max(1, options.page ?? 1);
  const tag = options.tag?.trim() || null;
  const year = options.year?.trim() || null;

  /* The filters stay apart from the visibility predicate because `listingSpanSelect` composes its own. */
  const narrowing: (SQL | undefined)[] = [];
  if (tag) narrowing.push(carriesTag(db, tag));
  if (year) {
    narrowing.push(sql`strftime('%Y', ${posts.publishAt}, 'unixepoch') = ${year}`);
  }
  const where = and(isBlogPost(), ...narrowing);

  // One round trip. The page is a subquery the tag join wraps; joining first would LIMIT
  // joined rows. The rest of newestFirstOrder breaks ties, or posts sharing `publish_at` interleave by tag.
  const pageOfPosts = db
    .select({ ...postCard, id: posts.id, groupKey: groupKey.as("group_key") })
    .from(posts)
    .where(where)
    .orderBy(...newestFirstOrder())
    .limit(perPage)
    .offset((page - 1) * perPage)
    .as("page_of_posts");

  // Named one by one: drizzle will not select a subquery as a nested object.
  const [statRows, joined] = await timed(options.timings, "d1_batch", () =>
    db.batch([
      listingSpanSelect(db, ...narrowing),
      db
        .select({
          id: pageOfPosts.id,
          slug: pageOfPosts.slug,
          title: pageOfPosts.title,
          description: pageOfPosts.description,
          publishAt: pageOfPosts.publishAt,
          updatedAt: pageOfPosts.updatedAt,
          coverImage: pageOfPosts.coverImage,
          coverAlt: pageOfPosts.coverAlt,
          readingTimeMinutes: pageOfPosts.readingTimeMinutes,
          featured: pageOfPosts.featured,
          series: pageOfPosts.series,
          part: pageOfPosts.part,
          // Aliased: `posts.slug` and `tags.slug` collide in drizzle's by-name row mapping.
          tagSlug: sql<string | null>`${tags.slug}`.as("tag_slug"),
        })
        .from(pageOfPosts)
        // LEFT, so a post with no tags keeps its row.
        .leftJoin(postTags, eq(postTags.postId, pageOfPosts.id))
        .leftJoin(tags, eq(tags.id, postTags.tagId))
        // Every post key before the tag key, or ties interleave rows of different posts.
        .orderBy(
          desc(pageOfPosts.publishAt),
          desc(pageOfPosts.groupKey),
          desc(sql`COALESCE(${pageOfPosts.part}, 0)`),
          desc(pageOfPosts.id),
          asc(tags.slug),
        ),
    ]),
  );

  const stats = statRows[0];
  const total = stats?.total ?? 0;

  // One entry per post; the Map keeps the ORDER BY's post and tag order.
  type Row = (typeof joined)[number];
  const byId = new Map<number, Omit<Row, "tagSlug"> & { tags: string[] }>();
  for (const row of joined) {
    const { tagSlug, ...post } = row;
    let entry = byId.get(post.id);
    if (!entry) {
      entry = { ...post, tags: [] };
      byId.set(post.id, entry);
    }
    if (tagSlug !== null) entry.tags.push(tagSlug);
  }

  return {
    posts: [...byId.values()].map(({ id: _dropped, ...rest }) => rest),
    total,
    page,
    perPage,
    pageCount: pageCount(total, perPage),
    /* The whole filtered list, not this page of it. */
    span: spanOf(stats),
  };
}

/** The featured post leads, then the hand-picked others, then the newest; with nothing featured the first of those leads. */
export async function listHomeStartHere(
  env: Env,
  options: { cards?: number; timings?: Timings } = {},
) {
  const db = getDb(env);
  /** Cards the section renders in total, lead included. */
  const cards = Math.max(1, options.cards ?? HOME_CARDS);

  /* `cards` others, not `cards - 1`: the extra row leads when nothing is featured. */
  const [featuredRows, pickedRows, newestRows, countRows] = await timed(options.timings, "d1_batch", () =>
    db.batch([
      db
        .select(postCard)
        .from(posts)
        .where(and(isBlogPost(), eq(posts.featured, true)))
        .orderBy(...newestFirstOrder())
        .limit(1),
      db
        .select(postCard)
        .from(posts)
        .where(and(isBlogPost(), eq(posts.featured, false), inArray(posts.slug, HOME_PICKS))),
      db
        .select(postCard)
        .from(posts)
        .where(and(isBlogPost(), eq(posts.featured, false)))
        .orderBy(...newestFirstOrder())
        .limit(cards),
      db.select({ total: count() }).from(posts).where(isBlogPost()),
    ]),
  );

  /* Which list leads is `startHere`'s rule, shared with `check:machine-readable`. */
  const otherRows = picksFirst([...pickedRows, ...newestRows]);
  const { featured, recent } = startHere(featuredRows, otherRows, cards);

  return { featured, recent, total: countRows[0]?.total ?? 0 };
}

export async function listBlogYears(env: Env) {
  return getDb(env)
    .select({
      year: sql<string>`strftime('%Y', ${posts.publishAt}, 'unixepoch')`.as("year"),
      total: count(),
    })
    .from(posts)
    .where(isBlogPost())
    .groupBy(sql`strftime('%Y', ${posts.publishAt}, 'unixepoch')`)
    .orderBy(desc(sql`strftime('%Y', ${posts.publishAt}, 'unixepoch')`));
}

export async function listSeriesParts(env: Env, series: string) {
  return getDb(env)
    .select({ slug: posts.slug, title: posts.title, part: posts.part })
    .from(posts)
    .where(and(isBlogPost(), eq(posts.series, series)))
    .orderBy(asc(posts.part));
}

export async function listBlogSeries(env: Env) {
  return getDb(env)
    .select({ name: posts.series, total: count(posts.id), lastmod: max(posts.updatedAt) })
    .from(posts)
    .where(and(isBlogPost(), isNotNull(posts.series)))
    .groupBy(posts.series)
    .orderBy(asc(posts.series));
}

/** Ambiguous slugs are refused. */
export async function getBlogSeries(env: Env, slug: string) {
  const all = await listBlogSeries(env);
  const matches = all.filter((row) => row.name !== null && seriesSlug(row.name) === slug);
  const row = matches.length === 1 ? matches[0] : undefined;
  if (!row) return null;
  return { name: row.name as string, slug, total: Number(row.total) };
}

/** One series as cards, oldest part first. Separate so post pages skip the tag join. */
export async function listSeriesPosts(
  env: Env,
  series: string,
  options: { page?: number; perPage?: number } = {},
) {
  const db = getDb(env);
  const perPage = options.perPage ?? POSTS_PER_PAGE;
  const page = Math.max(1, options.page ?? 1);
  const where = and(isBlogPost(), eq(posts.series, series));

  const [statRows, rows] = await db.batch([
    /* The same statement the index counts with, so every evidence row uses one arithmetic. */
    listingSpanSelect(db, eq(posts.series, series)),
    db
      .select({ ...postCard, id: posts.id })
      .from(posts)
      .where(where)
      .orderBy(asc(posts.part), asc(posts.id))
      .limit(perPage)
      .offset((page - 1) * perPage),
  ]);

  const total = Number(statRows[0]?.total ?? 0);
  const tagMap = await tagsForPosts(db, rows.map((r) => r.id));
  return {
    posts: rows.map(({ id, ...rest }) => ({ ...rest, tags: tagMap.get(id) ?? [] })),
    total,
    page,
    pageCount: pageCount(total, perPage),
    span: spanOf(statRows[0]),
  };
}

export async function listBlogTags(env: Env) {
  return getDb(env)
    .select({ slug: tags.slug, name: tags.name, total: count(posts.id), lastmod: max(posts.updatedAt) })
    .from(tags)
    .innerJoin(postTags, eq(postTags.tagId, tags.id))
    .innerJoin(posts, eq(posts.id, postTags.postId))
    .where(isBlogPost())
    .groupBy(tags.slug, tags.name)
    .orderBy(asc(tags.slug));
}

/** For the sitemap's image entries: each public post that renders an image, with its HTML. */
export async function listPostsWithImages(env: Env) {
  return getDb(env)
    .select({ slug: posts.slug, html: posts.html })
    .from(posts)
    .where(and(isBlogPost(), sql`${posts.html} LIKE '%<img%'`));
}

export async function getBlogPost(env: Env, slug: string) {
  const db = getDb(env);
  const rows = await db
    .select()
    .from(posts)
    .where(and(eq(posts.slug, slug), isBlogPost()))
    .limit(1);

  const post = rows[0];
  if (!post) return null;

  const tagMap = await tagsForPosts(db, [post.id]);
  return withNeighbours(db, post, tagMap.get(post.id) ?? []);
}

export type PostRow = typeof posts.$inferSelect;

/**
 * The Carrel preview's twin of getBlogPost, for a row built in memory rather than read from D1: the
 * same tags shape and the same neighbours, so the page renders exactly as it would once published.
 */
export async function previewBlogPost(env: Env, post: PostRow, tagSlugs: string[]) {
  return withNeighbours(getDb(env), post, [...tagSlugs].sort());
}

async function withNeighbours(db: DB, post: PostRow, tagSlugs: string[]) {
  // Neighbors use the same visibility gate, so prev/next cannot reach a draft. Ordered as the listing is
  // (newestFirstOrder), so posts sharing a publish_at are neighbors rather than skipped, and the parts of a
  // series follow one another. The slug is excluded so a previewed post, whose row may differ from the stored
  // one, is not its own neighbour.
  const at = post.publishAt ?? new Date();
  // This post's place in the order within its instant: its group key (the newest id of its series that day,
  // else its own id), worked out in the same statement as the neighbours so there is one round trip.
  const ownGroup = post.series
    ? sql`COALESCE((SELECT MAX(g.id) FROM posts g WHERE g.kind = 'post' AND g.status = ${PUBLISHED_STATUS} AND g.publish_at = ${Math.floor(at.getTime() / 1000)} AND g.series = ${post.series}), ${post.id})`
    : sql`${post.id}`;
  const ownPart = post.part ?? 0;
  /** Strictly before (`lt`) or after (`gt`) this post, by the listing's keys in turn. */
  const beyond = (cmp: typeof lt) =>
    or(
      cmp(posts.publishAt, at),
      and(
        eq(posts.publishAt, at),
        or(
          cmp(groupKey, ownGroup),
          and(
            eq(groupKey, ownGroup),
            or(cmp(partKey, ownPart), and(eq(partKey, ownPart), cmp(posts.id, post.id))),
          ),
        ),
      ),
    );
  const [[previous], [next]] = await Promise.all([
    db
      .select({ slug: posts.slug, title: posts.title })
      .from(posts)
      .where(and(isBlogPost(), ne(posts.slug, post.slug), beyond(lt)))
      .orderBy(...newestFirstOrder())
      .limit(1),
    db
      .select({ slug: posts.slug, title: posts.title })
      .from(posts)
      .where(and(isBlogPost(), ne(posts.slug, post.slug), beyond(gt)))
      .orderBy(...newestFirstOrder(asc))
      .limit(1),
  ]);

  return {
    ...post,
    tags: tagSlugs,
    previous: previous ?? null,
    next: next ?? null,
  };
}

/** Exempt from `publiclyVisible()`: matches `status = 'draft'` only, with the slug from a valid preview token. */
export async function getDraftPostForPreview(env: Env, slug: string) {
  const db = getDb(env);
  const rows = await db
    .select()
    .from(posts)
    .where(and(eq(posts.slug, slug), eq(posts.kind, "post"), eq(posts.status, "draft")))
    .limit(1);

  const post = rows[0];
  if (!post) return null;

  const tagMap = await tagsForPosts(db, [post.id]);

  // No neighbors: a draft has no place in the visible sequence.
  return {
    ...post,
    tags: tagMap.get(post.id) ?? [],
    previous: null as { slug: string; title: string } | null,
    next: null as { slug: string; title: string } | null,
  };
}

/** Admin only, so exempt from visibility. Media via `notTrashed()` to match the media page. */
export async function adminNavCounts(env: Env) {
  const db = getDb(env);
  const [postRow, mediaRow] = await Promise.all([
    db.select({ n: count() }).from(posts).where(eq(posts.kind, "post")),
    db.select({ n: count() }).from(media).where(notTrashed()),
  ]);
  return { posts: Number(postRow[0]?.n ?? 0), media: Number(mediaRow[0]?.n ?? 0) };
}

export async function listAllPostsForAdmin(env: Env) {
  return getDb(env)
    .select({
      slug: posts.slug,
      title: posts.title,
      status: posts.status,
      publishAt: posts.publishAt,
      updatedAt: posts.updatedAt,
      featured: posts.featured,
    })
    .from(posts)
    .where(eq(posts.kind, "post"))
    .orderBy(desc(posts.publishAt));
}

/** Corpus for `withRelated`, drafts included: it filters candidates itself. */
export async function listPostCorpusForRelated(env: Env) {
  const rows = await getDb(env)
    .select({
      slug: posts.slug,
      title: posts.title,
      status: posts.status,
      publishAt: posts.publishAt,
      // For each neighbor's description in a saved related list; not scored.
      description: posts.description,
    })
    .from(posts)
    .where(and(eq(posts.kind, "post"), isNotNull(posts.sourcePath)));
  const tagRows = await listAllPostTagsForAdmin(env);

  const tagsBySlug = groupValues(tagRows, (row) => row.slug, (row) => row.tag);
  return rows.map((r) => ({ ...r, tags: tagsBySlug.get(r.slug) ?? [] }));
}

/** Drafts included: it filters the linking post itself. Carries `html` because a backlink is read out of a rendered body. */
export async function listPostLinkCorpus(env: Env) {
  return getDb(env)
    .select({
      slug: posts.slug,
      title: posts.title,
      status: posts.status,
      publishAt: posts.publishAt,
      html: posts.html,
    })
    .from(posts)
    .where(and(eq(posts.kind, "post"), isNotNull(posts.sourcePath)));
}

/** The stored `related` and `backlinks` JSON of every file-sourced post, for the save's neighbour refresh to diff against. */
export async function listPostDerivedLists(env: Env) {
  return getDb(env)
    .select({ slug: posts.slug, related: posts.related, backlinks: posts.backlinks })
    .from(posts)
    .where(and(eq(posts.kind, "post"), isNotNull(posts.sourcePath)));
}

/** Markdown and covers for the citation scan, drafts included: they still block deletion. */
export async function listPostSourcesForCitations(env: Env) {
  return getDb(env)
    .select({
      slug: posts.slug,
      title: posts.title,
      body: posts.body,
      coverImage: posts.coverImage,
    })
    .from(posts)
    .where(eq(posts.kind, "post"));
}

/** Operator list_posts projection, drafts included. */
export async function listPostsForOperator(env: Env) {
  const rows = await getDb(env)
    .select({
      slug: posts.slug,
      title: posts.title,
      description: posts.description,
      status: posts.status,
      publishAt: posts.publishAt,
      updatedAt: posts.updatedAt,
      series: posts.series,
      part: posts.part,
      sourcePath: posts.sourcePath,
    })
    .from(posts)
    .where(and(eq(posts.kind, "post"), isNotNull(posts.sourcePath)))
    .orderBy(asc(posts.slug));
  const tagRows = await listAllPostTagsForAdmin(env);

  const tagsBySlug = groupValues(tagRows, (row) => row.slug, (row) => row.tag);
  return rows.map((r) => ({ ...r, tags: tagsBySlug.get(r.slug) ?? [] }));
}

export async function getAdminPostRow(env: Env, slug: string) {
  return (
    (await getDb(env)
      .select({
        html: posts.html,
        readingTimeMinutes: posts.readingTimeMinutes,
      })
      .from(posts)
      .where(and(eq(posts.kind, "post"), eq(posts.slug, slug)))
      .get()) ?? null
  );
}

/** All post tags, drafts included, for the admin filter; `listBlogTags` hides drafts. */
export async function listAllPostTagsForAdmin(env: Env) {
  return getDb(env)
    .select({ slug: posts.slug, tag: tags.slug })
    .from(postTags)
    .innerJoin(posts, eq(posts.id, postTags.postId))
    .innerJoin(tags, eq(tags.id, postTags.tagId))
    .where(eq(posts.kind, "post"))
    .orderBy(asc(tags.slug));
}

/** `id` is for the tag join, stripped after. */
const FEED_COLUMNS = {
  id: posts.id,
  slug: posts.slug,
  title: posts.title,
  description: posts.description,
  publishAt: posts.publishAt,
  updatedAt: posts.updatedAt,
  coverImage: posts.coverImage,
};

type FeedOptions = {
  perPage?: number;
  tag?: string | null;
  series?: string | null;
  /** `part` for a series feed; anything else is newest first. */
  orderBy?: "date" | "part";
};

/** The filter and order both feed listings share. Narrowed in the query: slicing afterwards would cap before narrowing. */
function feedScope(db: DB, options: FeedOptions) {
  const clauses = [isBlogPost()];
  if (options.tag) clauses.push(carriesTag(db, options.tag));
  if (options.series) clauses.push(eq(posts.series, options.series));
  const order =
    options.orderBy === "part" ? [asc(posts.part), asc(posts.id)] : newestFirstOrder();
  return { where: and(...clauses), order };
}

/** Attaches each row's tags and strips the `id` the join needed. */
async function withFeedTags<R extends { id: number }>(db: DB, rows: R[]) {
  const tagMap = await tagsForPosts(db, rows.map((r) => r.id));
  return rows.map(({ id, ...rest }) => ({ ...rest, tags: tagMap.get(id) ?? [] }));
}

/** Visible posts with markdown, newest first; limited in SQL because bodies are heavy. */
export async function listBlogPostsFullText(env: Env, options: FeedOptions = {}) {
  const db = getDb(env);
  const { where, order } = feedScope(db, options);
  const query = db
    .select({ ...FEED_COLUMNS, body: posts.body })
    .from(posts)
    .where(where)
    .orderBy(...order);
  return withFeedTags(db, await (options.perPage ? query.limit(options.perPage) : query));
}

/** Same posts with rendered HTML, for RSS. A sibling, not a union-returning flag. */
export async function listBlogPostsRendered(env: Env, options: FeedOptions = {}) {
  const db = getDb(env);
  const { where, order } = feedScope(db, options);
  const query = db
    .select({ ...FEED_COLUMNS, html: posts.html })
    .from(posts)
    .where(where)
    .orderBy(...order);
  return withFeedTags(db, await (options.perPage ? query.limit(options.perPage) : query));
}

export async function getBlogPostMarkdown(env: Env, slug: string) {
  const rows = await getDb(env)
    .select({ body: posts.body, title: posts.title })
    .from(posts)
    .where(and(eq(posts.slug, slug), isBlogPost()))
    .limit(1);
  return rows[0] ?? null;
}