, see lib/ingest/feed.ts). Last resort: a deterministic
+ * per-category brand cover baked into /public/covers/ so that every card,
+ * article page and social preview always shows a visual related to the
+ * piece — never a bare glyph (2026-08-17 "every article has a photo").
+ */
+
+/** Category slugs that have a generated cover asset. */
+const COVER_SLUGS = ['top-stories', 'canada', 'national'] as const;
+type CoverSlug = (typeof COVER_SLUGS)[number];
+
+export function categoryCover(category?: string | null): string {
+ const slug = (category ?? '')
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+ if ((COVER_SLUGS as readonly string[]).includes(slug)) {
+ return `/covers/${slug}.svg`;
+ }
+ return '/covers/top-stories.svg';
+}
+
+export function resolveCoverImage(
+ image: string | null | undefined,
+ category?: string | null,
+): string {
+ return (image ?? '').trim() ? image! : categoryCover(category);
+}
diff --git a/src/lib/ingest/pipeline.ts b/src/lib/ingest/pipeline.ts
index 927db6b..3eaa586 100644
--- a/src/lib/ingest/pipeline.ts
+++ b/src/lib/ingest/pipeline.ts
@@ -51,11 +51,15 @@ export async function ingestFeed(
(i) => !i.publishedAt || i.publishedAt >= cutoff,
);
- // Build a set of keys already in the DB (bounded to recent window).
+ // Build a set of keys already in the DB. Deliberately NOT scoped to the
+ // publishedAt window: items saved without a pubDate (publishedAt=null)
+ // would otherwise be invisible to every dedupe set and re-collide on the
+ // @unique(guid) constraint on every pass (P2002 "item errors", fixed
+ // 2026-08-18). Costs 3 scalar columns, so the full-history scan is cheap.
const existing = await prisma.article.findMany({
- where: { publishedAt: { gte: cutoff }, guid: { not: null } },
+ where: { guid: { not: null } },
select: { guid: true, dedupKey: true, sourceUrl: true },
- take: 5000,
+ take: 20000,
});
const seenGuid = new Set(existing.map((a) => a.guid).filter(Boolean) as string[]);
const seenDedup = new Set(existing.map((a) => a.dedupKey));
@@ -98,6 +102,7 @@ export async function ingestFeed(
const sourceUrl = item.link;
const canonicalUrl = `${env.siteUrl.replace(/\/$/, '')}/article/${slug}`;
+ try {
await prisma.article.create({
data: {
feedId,
@@ -117,6 +122,16 @@ export async function ingestFeed(
status: 'fetched',
},
});
+ } catch (err) {
+ if (/P2002|Unique constraint failed/.test((err as Error).message)) {
+ // A row claiming this guid/dedupKey/slug was written between the
+ // pre-scan and the insert (concurrent pass or a pre-window row).
+ // Count it as a duplicate, not a spurious item error.
+ result.duplicates += 1;
+ continue;
+ }
+ throw err;
+ }
result.newArticles += 1;
} catch (err) {
result.errors.push(`item "${item.title?.slice(0, 60)}": ${(err as Error).message}`);
diff --git a/src/lib/llm/synthesize.ts b/src/lib/llm/synthesize.ts
index 9c55541..6acc23f 100644
--- a/src/lib/llm/synthesize.ts
+++ b/src/lib/llm/synthesize.ts
@@ -64,13 +64,29 @@ export async function synthesizePending(limit: number = 24) {
let ok = 0;
let failed = 0;
+ // Dead-letter: a row that is still `fetched` after 24h of continuous
+ // failure will never succeed on its own (poison content, oversized
+ // source, provider rejection). Mark it `failed` so it stops consuming
+ // a slot in every cron pass and out of the backlog. Manual re-queue by
+ // resetting status to `fetched` if the cause is later fixed.
+ const deadAfter = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const a of pending) {
try {
await synthesizeArticle(a.id);
ok += 1;
} catch (err) {
failed += 1;
- console.error(`[synth] failed article ${a.slug}:`, (err as Error).message);
+ const dead = a.createdAt < deadAfter;
+ const why = (err as Error).message;
+ if (dead) {
+ await prisma.article.update({
+ where: { id: a.id },
+ data: { status: 'failed' },
+ });
+ console.error(`[synth] DEAD-LETTER ${a.slug} (failed >24h): ${why}`);
+ } else {
+ console.error(`[synth] failed article ${a.slug}: ${why}`);
+ }
}
}
return { total: pending.length, ok, failed };