- catalog: apply bulk offer edits in a single transaction with one RCON refresh instead of N round-trips, virtualize the items table past 150 rows and cap the shop preview at 200 tiles - cache: lift the public cache TTLs and the boot warm-up into env-tunable constants (src/lib/perf-ttls.ts; CMS_*_TTL_SEC, CMS_WARMUP_ENABLED) - auth: resolve the captcha provider, site key and secret from CAPTCHA_PROVIDER, TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY when the admin settings are unset; an explicit DB value (including "none") still wins, so Turnstile can be enabled per-deploy without touching the DB.
113 lines
3.6 KiB
TypeScript
113 lines
3.6 KiB
TypeScript
import "server-only";
|
|
import { and, asc, count, desc, eq, like, or, sql } from "drizzle-orm";
|
|
import { db, WebsiteArticles } from "@/lib/db";
|
|
import { NEWS_ARCHIVE_TTL_MS } from "@/lib/perf-ttls";
|
|
import { cacheSafe } from "@/lib/redis-cache";
|
|
import { cacheNews } from "@/lib/services/news-cache";
|
|
|
|
/**
|
|
* Fresh for a minute, then servable stale for another minute. Both the landing
|
|
* page and /search hit this, and the COUNT(*) over a leading-wildcard LIKE is a
|
|
* full scan of website_articles — the reason the archive felt slow once the
|
|
* table grew. Publishing an article rotates the news revision and drops these
|
|
* entries immediately, so the window only applies when pub/sub cannot be
|
|
* delivered.
|
|
*/
|
|
const ARCHIVE_TTL_MS = NEWS_ARCHIVE_TTL_MS;
|
|
|
|
async function queryNewsArchive(
|
|
options: { search?: string; page?: number; order?: string } = {},
|
|
) {
|
|
const search = (typeof options.search === "string" ? options.search : "")
|
|
.trim()
|
|
.slice(0, 191);
|
|
const order = options.order === "oldest" ? "oldest" : "newest";
|
|
const publishedAt =
|
|
sql<Date | null>`COALESCE(${WebsiteArticles.publishAt}, ${WebsiteArticles.createdAt})`.mapWith(
|
|
WebsiteArticles.createdAt,
|
|
);
|
|
const where = and(
|
|
eq(WebsiteArticles.status, "published"),
|
|
or(
|
|
sql`${WebsiteArticles.publishAt} IS NULL`,
|
|
sql`${WebsiteArticles.publishAt} <= NOW()`,
|
|
),
|
|
search
|
|
? or(
|
|
like(WebsiteArticles.title, `%${search}%`),
|
|
like(WebsiteArticles.shortStory, `%${search}%`),
|
|
)
|
|
: undefined,
|
|
);
|
|
const totals = await db
|
|
.select({ value: count() })
|
|
.from(WebsiteArticles)
|
|
.where(where);
|
|
const total = Number(totals[0]?.value ?? 0);
|
|
const perPage = 24;
|
|
const lastPage = Math.max(1, Math.ceil(total / perPage));
|
|
const page = Math.min(
|
|
lastPage,
|
|
Number.isFinite(options.page)
|
|
? Math.max(1, Math.trunc(options.page ?? 1))
|
|
: 1,
|
|
);
|
|
const sort = order === "oldest" ? asc : desc;
|
|
const rows = await db
|
|
.select({
|
|
slug: WebsiteArticles.slug,
|
|
title: WebsiteArticles.title,
|
|
shortStory: WebsiteArticles.shortStory,
|
|
image: WebsiteArticles.image,
|
|
createdAt: publishedAt,
|
|
})
|
|
.from(WebsiteArticles)
|
|
.where(where)
|
|
.orderBy(sort(publishedAt), sort(WebsiteArticles.id))
|
|
.limit(perPage)
|
|
.offset((page - 1) * perPage);
|
|
// cacheSafe() flattens the Date into the same ISO string on both cache
|
|
// paths. Without it a Redis hit would hand callers a string where a miss
|
|
// hands them a Date, and the archive grid formats this field directly.
|
|
return cacheSafe({ rows, total, page, perPage, lastPage, search, order });
|
|
}
|
|
|
|
/**
|
|
* Keyed on the REQUESTED page rather than the clamped one, so two requests that
|
|
* clamp onto the same page cannot alias each other's entry.
|
|
*/
|
|
function archiveKey(options: {
|
|
search?: string;
|
|
page?: number;
|
|
order?: string;
|
|
}): string {
|
|
const search = (typeof options.search === "string" ? options.search : "")
|
|
.trim()
|
|
.slice(0, 191);
|
|
const order = options.order === "oldest" ? "oldest" : "newest";
|
|
const page = Number.isFinite(options.page)
|
|
? Math.trunc(options.page ?? 1)
|
|
: 1;
|
|
return `news:archive:${order}:${page}:${search}`;
|
|
}
|
|
|
|
export async function loadNewsArchive(
|
|
options: { search?: string; page?: number; order?: string } = {},
|
|
) {
|
|
const key = archiveKey(options);
|
|
// cacheNews() namespaces by the news revision and owns the stale window.
|
|
const result = await cacheNews(key, ARCHIVE_TTL_MS, () =>
|
|
queryNewsArchive(options),
|
|
);
|
|
|
|
// Revive the one field callers treat as a Date. Kept outside the cache so
|
|
// the cached value stays plain JSON.
|
|
return {
|
|
...result,
|
|
rows: result.rows.map((row) => ({
|
|
...row,
|
|
createdAt: new Date(row.createdAt),
|
|
})),
|
|
};
|
|
}
|