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
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| publiclyVisibleSlugs | 33 to 43 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:42> | 42 to 42 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| nextScheduledPublishAt | 49 to 63 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| newestFirstOrder | 75 to 80 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listingSpanSelect | 103 to 117 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| spanOf | 120 to 126 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| carriesTag | 129 to 138 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| getBlogTag | 141 to 151 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| groupValues | 154 to 162 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| tagsForPosts | 164 to 175 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:174> | 174 to 174 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:174> | 174 to 174 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listBlogPosts | 177 to 273 | 11 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:213> | 213 to 245 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:265> | 265 to 265 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listHomeStartHere | 276 to 312 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:285> | 285 to 304 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listBlogYears | 314 to 324 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listSeriesParts | 326 to 332 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listBlogSeries | 334 to 341 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| getBlogSeries | 344 to 350 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:346> | 346 to 346 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listSeriesPosts | 353 to 384 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:376> | 376 to 376 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:378> | 378 to 378 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listBlogTags | 386 to 395 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listPostsWithImages | 398 to 403 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| getBlogPost | 405 to 418 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| previewBlogPost | 426 to 428 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| withNeighbours | 430 to 478 | 6 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| beyond | 443 to 456 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| getDraftPostForPreview | 481 to 501 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| adminNavCounts | 504 to 511 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listAllPostsForAdmin | 513 to 526 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listPostCorpusForRelated | 529 to 545 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:543> | 543 to 543 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:543> | 543 to 543 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:544> | 544 to 544 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listPostLinkCorpus | 548 to 559 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listPostDerivedLists | 562 to 567 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listPostSourcesForCitations | 570 to 580 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listPostsForOperator | 583 to 603 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:601> | 601 to 601 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:601> | 601 to 601 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:602> | 602 to 602 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| getAdminPostRow | 605 to 616 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listAllPostTagsForAdmin | 619 to 627 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| feedScope | 649 to 656 | 4 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| withFeedTags | 659 to 662 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:660> | 660 to 660 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:661> | 661 to 661 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listBlogPostsFullText | 665 to 674 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listBlogPostsRendered | 677 to 686 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| getBlogPostMarkdown | 688 to 695 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No 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;
}