From 6d6b8fc700a888feac1fa52d99ff5281dace1ffc Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Sat, 18 Jul 2026 18:24:52 -0700 Subject: [PATCH] Optimize feed generation and publication queries (#360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Cut per-request query weight across the /lish read paths Follow-up round of feed/publication query optimization: - The /lish layout metadata query pulled every subscription row and every document's full record jsonb on every view of every publication page, then read only uri + record. Select just those two columns. - getPostPageData embedded each sibling post's full record only to derive prev/next links; project uri, sort_date, and the title/publishedAt scalars instead (publishedAt keeps normalizeDocument's gating of unpublished pub.leaflet records). - fetchPublicationForPage loaded the whole post list (full record jsonb plus three count embeds) even though most requests through it — every post URL, any page without a posts list — never render one. The publication row query no longer embeds documents; a separate fetchPublicationPostRows query runs only where a post list is actually rendered (legacy homepage, archive, posts-list blocks inside published pages). - generateFeed sorted and truncated the feed's 50 items in JS after fetching a publication's entire document history; order/limit at the database via the sort_date index and project only uri/data/sort_date. - Feed responses now send Last-Modified (the newest post's date) and honor If-Modified-Since for the readers that don't send If-None-Match; the JSON feed route gains the same conditional handling rss/atom already had. - The leaflet.pub feed-skeleton route selected entire document rows to read one post ref; project just the postRef/bskyPostRef/publishedAt fields, matching the feed service's existing fix. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017w9GceB5kdYvDiZtgEnsGL * Overlap independent fetches and simplify the deferred post-list plumbing Parallelization: - Publication homepage: the author profile lookup doesn't depend on the post rows, so run it alongside the rows -> bylines chain instead of serializing all three. - Archive: the /archive shadowing check and the post rows are independent; fetch them concurrently (the rows only go unused in the rare shadowed case). - PublicationPageRenderer: start the post-list query before the block-resource fetches (bsky API calls) so the two overlap. - generateFeed: kick off the react-dom/server import before the queries rather than blocking on it first. Simplification: - DefaultPublicationHomepage no longer accepts a raw documents_in_publications embed; every caller passes built posts (the theme preview builds them client-side, where bylines already resolve), removing the optional-prop fallback branch. - feedResponse expresses the If-None-Match / If-Modified-Since precedence as one not-modified decision with a single headers object. - Drop a redundant .slice() on buildPublicationPosts output (it already returns a fresh array). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017w9GceB5kdYvDiZtgEnsGL --------- Co-authored-by: Claude --- .../DefaultPublicationHomepage.tsx | 23 ++------ .../[rkey]/PublicationPageRenderer.tsx | 22 +++++--- .../[publication]/[rkey]/getPostPageData.ts | 24 +++------ .../lish/[did]/[publication]/archive/page.tsx | 21 +++++--- .../lish/[did]/[publication]/atom/route.ts | 7 ++- .../lish/[did]/[publication]/feedResponse.ts | 38 +++++++++++--- .../lish/[did]/[publication]/generateFeed.ts | 52 +++++++++---------- .../[publication]/getPublicationForPage.ts | 23 ++++++-- .../lish/[did]/[publication]/json/route.ts | 14 ++--- app/(app)/lish/[did]/[publication]/layout.tsx | 7 +-- app/(app)/lish/[did]/[publication]/page.tsx | 23 +++++--- .../lish/[did]/[publication]/rss/route.ts | 1 + .../theme-settings/PubPreview.tsx | 2 + app/(app)/lish/feeds/[...path]/route.ts | 24 +++++---- 14 files changed, 158 insertions(+), 123 deletions(-) diff --git a/app/(app)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx b/app/(app)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx index ca6d298d..f3ae2aa9 100644 --- a/app/(app)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx +++ b/app/(app)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx @@ -12,10 +12,7 @@ import { PublicationPostsList, type PublicationPostsListFakePost, } from "./PublicationPostsList"; -import { - buildPublicationPosts, - type PublicationPostsListPost, -} from "./buildPublicationPosts"; +import { type PublicationPostsListPost } from "./buildPublicationPosts"; type FakePost = PublicationPostsListFakePost; @@ -42,31 +39,19 @@ export const DefaultPublicationHomepage = ({ record_uri: string | null; sort_order: string; }[]; - documents_in_publications: { - documents: { - uri: string; - data: unknown; - comments_on_documents: { count: number }[]; - document_mentions_in_bsky: { count: number }[]; - recommends_on_documents: { count: number }[]; - } | null; - }[]; }; did: string; profile: { did: string; displayName?: string; handle: string } | undefined; showPageBackground: boolean | undefined; fakePosts?: FakePost[]; - // Posts with bylines resolved server-side. When omitted (client theme - // preview) the list is built here and bylines resolve on the client. + // Server callers attach bylines before passing these; the client theme + // preview passes them byline-less and PublicationPostsList resolves them. posts?: PublicationPostsListPost[]; }) => { const newsletterMode = !!publication.publication_newsletter_settings?.enabled; // publication_pages rows are published state, so the nav reads them directly. const navPages = publishedNavPages(publication.publication_pages); - const posts: PublicationPostsListPost[] = fakePosts - ? [] - : (resolvedPosts ?? - buildPublicationPosts(publication.documents_in_publications)); + const posts: PublicationPostsListPost[] = fakePosts ? [] : resolvedPosts ?? []; return ( <> + PubLeafletBlocksPostsList.isMain(b.block), + ); + + // The document list is only loaded when the page renders one — callers that + // already have it (theme preview) pass it in, everyone else queries here. + // Started before the block-resource fetches so the two overlap. + const postRowsPromise = postsListBlocks.length + ? publication.documents_in_publications ?? + fetchPublicationPostRows(publication.uri) + : []; + const { bskyPostData, standardSitePostData: standardSitePosts, @@ -107,17 +120,10 @@ export async function PublicationPageRenderer({ prerenderedCodeBlocks, } = await collectAndFetchBlockResources({ agent, pages: resourcePages }); - const postsListBlocks = allBlocks.filter((b) => - PubLeafletBlocksPostsList.isMain(b.block), - ); - - // The page query already loaded every document, so build the list in memory. // Per distinct tag-filter signature, ship the full ordered URI list plus a // byline-resolved first batch (in the SSR HTML); the client hydrates later // batches by URI on scroll via getPostsByUris. - const allPosts = postsListBlocks.length - ? buildPublicationPosts(publication.documents_in_publications) - : []; + const allPosts = buildPublicationPosts(await postRowsPromise); const distinctFilters = new Map(); for (const b of postsListBlocks) { const filterByTags = (b.block as PubLeafletBlocksPostsList.Main) diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/getPostPageData.ts b/app/(app)/lish/[did]/[publication]/[rkey]/getPostPageData.ts index 11efa098..f730e579 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/getPostPageData.ts +++ b/app/(app)/lish/[did]/[publication]/[rkey]/getPostPageData.ts @@ -30,7 +30,7 @@ export async function getPostPageData( uri, comments_on_documents(record), documents_in_publications(publications(*, - documents_in_publications(documents(uri, data)), + documents_in_publications(documents(uri, sort_date, title:data->>title, publishedAt:data->>publishedAt)), publication_subscriptions(*), publication_newsletter_settings(enabled), publication_pages(id, path, title, record_uri, sort_order)) @@ -102,24 +102,16 @@ export async function getPostPageData( ?.documents_in_publications; if (currentPublishedAt && allDocs) { - // Filter and sort documents by publishedAt + // The publishedAt filter mirrors normalizeDocumentRecord's gating of + // unpublished pub.leaflet records without paying for the full data jsonb + // of every sibling post. const sortedDocs = allDocs - .map((dip) => { - const normalizedData = normalizeDocumentRecord( - dip?.documents?.data, - dip?.documents?.uri, - ); - return { - uri: dip?.documents?.uri, - title: normalizedData?.title, - publishedAt: normalizedData?.publishedAt, - }; - }) - .filter((doc) => doc.publishedAt && doc.title) // Only include docs with publishedAt and valid data + .flatMap((dip) => (dip.documents ? [dip.documents] : [])) + .filter((doc) => doc.publishedAt && doc.title) .sort( (a, b) => - new Date(a.publishedAt!).getTime() - - new Date(b.publishedAt!).getTime(), + new Date(a.sort_date || 0).getTime() - + new Date(b.sort_date || 0).getTime(), ); // Find current document index diff --git a/app/(app)/lish/[did]/[publication]/archive/page.tsx b/app/(app)/lish/[did]/[publication]/archive/page.tsx index f9747829..325c9a47 100644 --- a/app/(app)/lish/[did]/[publication]/archive/page.tsx +++ b/app/(app)/lish/[did]/[publication]/archive/page.tsx @@ -19,7 +19,10 @@ import { import { DocumentPageRenderer } from "../[rkey]/DocumentPageRenderer"; import { postPageMetadata } from "../[rkey]/postPageMetadata"; import { buildPublicationPosts } from "../buildPublicationPosts"; -import { fetchPublicationForPage } from "../getPublicationForPage"; +import { + fetchPublicationForPage, + fetchPublicationPostRows, +} from "../getPublicationForPage"; import { tryRenderPublicationPage } from "../tryRenderPublicationPage"; import { resolveDocumentFilter } from "../resolveDocumentFilter"; import { publicationAlternates } from "../publicationAlternates"; @@ -83,11 +86,14 @@ export default async function PublicationArchive(props: { path: "/archive", }); if (pageRender) return pageRender; - let { data: shadowingDocs } = await supabaseServerClient - .from("documents") - .select("uri") - .or(await resolveDocumentFilter(did, publication_name, "archive")) - .limit(1); + // The post rows are fetched alongside the shadowing check; they only go + // unused in the rare case a document publishes at the /archive path. + const [{ data: shadowingDocs }, postRows] = await Promise.all([ + resolveDocumentFilter(did, publication_name, "archive").then((filter) => + supabaseServerClient.from("documents").select("uri").or(filter).limit(1), + ), + fetchPublicationPostRows(publication.uri), + ]); if (shadowingDocs?.[0]) return ( { const aDate = a.record.publishedAt ? new Date(a.record.publishedAt).getTime() diff --git a/app/(app)/lish/[did]/[publication]/atom/route.ts b/app/(app)/lish/[did]/[publication]/atom/route.ts index aa5926f2..4e83dd48 100644 --- a/app/(app)/lish/[did]/[publication]/atom/route.ts +++ b/app/(app)/lish/[did]/[publication]/atom/route.ts @@ -17,5 +17,10 @@ export async function GET( return feed; } - return feedResponse(req, feed.atom1(), "application/atom+xml"); + return feedResponse( + req, + feed.atom1(), + "application/atom+xml", + feed.options.updated, + ); } diff --git a/app/(app)/lish/[did]/[publication]/feedResponse.ts b/app/(app)/lish/[did]/[publication]/feedResponse.ts index f219b796..b0cbabb3 100644 --- a/app/(app)/lish/[did]/[publication]/feedResponse.ts +++ b/app/(app)/lish/[did]/[publication]/feedResponse.ts @@ -9,16 +9,29 @@ const CACHE_HEADERS = { // and nearly all of them send If-None-Match — but transfer out is billed // even on CDN cache hits, so serving the full document to every poll pays // for the same unchanged bytes over and over. A content-derived ETag lets -// an unchanged feed answer with an empty 304 instead. -export function feedResponse(req: Request, body: string, contentType: string) { +// an unchanged feed answer with an empty 304 instead. `lastModified` (the +// newest post's date) covers the readers that only send If-Modified-Since. +export function feedResponse( + req: Request, + body: string, + contentType: string, + lastModified?: Date, +) { const etag = `"${createHash("sha1").update(body).digest("base64url")}"`; - if (etagMatches(req.headers.get("if-none-match"), etag)) - return new Response(null, { - status: 304, - headers: { ETag: etag, ...CACHE_HEADERS }, - }); + const headers = { + ETag: etag, + ...(lastModified && { "Last-Modified": lastModified.toUTCString() }), + ...CACHE_HEADERS, + }; + // Per RFC 9110, If-Modified-Since only applies when If-None-Match is absent. + const ifNoneMatch = req.headers.get("if-none-match"); + const notModified = ifNoneMatch + ? etagMatches(ifNoneMatch, etag) + : !!lastModified && + modifiedSince(req.headers.get("if-modified-since"), lastModified); + if (notModified) return new Response(null, { status: 304, headers }); return new Response(body, { - headers: { "Content-Type": contentType, ETag: etag, ...CACHE_HEADERS }, + headers: { "Content-Type": contentType, ...headers }, }); } @@ -31,3 +44,12 @@ function etagMatches(ifNoneMatch: string | null, etag: string) { .map((t) => t.trim().replace(/^W\//, "")) .includes(etag); } + +function modifiedSince(ifModifiedSince: string | null, lastModified: Date) { + if (!ifModifiedSince) return false; + const since = new Date(ifModifiedSince).getTime(); + if (isNaN(since)) return false; + // Compare at whole-second granularity — Last-Modified drops milliseconds, + // so readers echo back a truncated timestamp. + return Math.floor(lastModified.getTime() / 1000) <= Math.floor(since / 1000); +} diff --git a/app/(app)/lish/[did]/[publication]/generateFeed.ts b/app/(app)/lish/[did]/[publication]/generateFeed.ts index d8c71205..6a224a67 100644 --- a/app/(app)/lish/[did]/[publication]/generateFeed.ts +++ b/app/(app)/lish/[did]/[publication]/generateFeed.ts @@ -56,16 +56,13 @@ export async function generateFeed( did: string, publication_name: string, ): Promise> { - let renderToReadableStream = await import("react-dom/server").then( + // Started before the queries so the module load (cold starts) overlaps them. + let renderToReadableStreamPromise = import("react-dom/server").then( (module) => module.renderToReadableStream, ); let { data: publications, error } = await supabaseServerClient .from("publications") - .select( - `*, - documents_in_publications(documents(*)) - `, - ) + .select(`uri, record`) .eq("identity_did", did) .or(publicationNameOrUriFilter(did, publication_name)) .order("uri", { ascending: false }) @@ -97,19 +94,23 @@ export async function generateFeed( ); const description = pubRecord?.description ?? rawRecord?.description; - let docs = publication.documents_in_publications - .sort((a, b) => { - const dateA = a.documents?.sort_date - ? new Date(a.documents.sort_date).getTime() - : 0; - const dateB = b.documents?.sort_date - ? new Date(b.documents.sort_date).getTime() - : 0; - return dateB - dateA; // Sort in descending order (newest first) - }) - .slice(0, FEED_ITEM_LIMIT); + let { data: docs, error: docsError } = await supabaseServerClient + .from("documents") + .select( + `uri, data, sort_date, + documents_in_publications!inner(publication)`, + ) + .eq("documents_in_publications.publication", publication.uri) + .order("sort_date", { ascending: false }) + .order("uri", { ascending: false }) + .limit(FEED_ITEM_LIMIT); + if (docsError) { + console.error(docsError); + return new NextResponse(null, { status: 500 }); + } + docs = docs ?? []; - const newest = parseDate(docs[0]?.documents?.sort_date); + const newest = parseDate(docs[0]?.sort_date); const feed = new Feed({ title: stripInvalidXmlChars(name), @@ -133,13 +134,10 @@ export async function generateFeed( }, }); + const renderToReadableStream = await renderToReadableStreamPromise; for (const doc of docs) { - if (!doc.documents) continue; - const record = normalizeDocumentRecord( - doc.documents?.data, - doc.documents?.uri, - ); - const uri = new AtUri(doc.documents?.uri); + const record = normalizeDocumentRecord(doc.data, doc.uri); + const uri = new AtUri(doc.uri); if (!record) continue; let blocks: PubLeafletPagesLinearDocument.Block[] = []; @@ -151,7 +149,7 @@ export async function generateFeed( } const docUrl = absolutize( - getDocumentURL(record, doc.documents.uri, pubRecord ?? pubInput), + getDocumentURL(record, doc.uri, pubRecord ?? pubInput), ); let content: string; @@ -176,9 +174,7 @@ export async function generateFeed( } const date = - parseDate(record.publishedAt) ?? - parseDate(doc.documents.sort_date) ?? - new Date(0); + parseDate(record.publishedAt) ?? parseDate(doc.sort_date) ?? new Date(0); feed.addItem({ title: stripInvalidXmlChars(record.title || ""), diff --git a/app/(app)/lish/[did]/[publication]/getPublicationForPage.ts b/app/(app)/lish/[did]/[publication]/getPublicationForPage.ts index 5b7b83a1..13a41dae 100644 --- a/app/(app)/lish/[did]/[publication]/getPublicationForPage.ts +++ b/app/(app)/lish/[did]/[publication]/getPublicationForPage.ts @@ -10,11 +10,7 @@ export async function fetchPublicationForPage( .select( `uri, name, identity_did, record, publication_newsletter_settings(enabled), - publication_pages(id, path, title, record, record_uri, sort_order), - documents_in_publications(documents(uri, data, - comments_on_documents(count), - document_mentions_in_bsky(count), - recommends_on_documents(count)))`, + publication_pages(id, path, title, record, record_uri, sort_order)`, ) .eq("identity_did", did) .or(publicationNameOrUriFilter(did, publicationName)) @@ -26,3 +22,20 @@ export async function fetchPublicationForPage( export type PublicationForPage = NonNullable< Awaited> >; + +// Fetched separately from the publication row because most requests through +// fetchPublicationForPage (every post URL, any page without a posts list) +// never render the post list — loading every document's full record jsonb up +// front made the common path pay for the rare one. +export async function fetchPublicationPostRows(publicationUri: string) { + const { data } = await supabaseServerClient + .from("documents_in_publications") + .select( + `documents(uri, data, + comments_on_documents(count), + document_mentions_in_bsky(count), + recommends_on_documents(count))`, + ) + .eq("publication", publicationUri); + return data ?? []; +} diff --git a/app/(app)/lish/[did]/[publication]/json/route.ts b/app/(app)/lish/[did]/[publication]/json/route.ts index d32b24a7..87426471 100644 --- a/app/(app)/lish/[did]/[publication]/json/route.ts +++ b/app/(app)/lish/[did]/[publication]/json/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { generateFeed } from "../generateFeed"; +import { feedResponse } from "../feedResponse"; export async function GET( req: Request, @@ -16,11 +17,10 @@ export async function GET( return feed; } - return new Response(feed.json1(), { - headers: { - "Content-Type": "application/feed+json", - "Cache-Control": "s-maxage=300, stale-while-revalidate=3600", - "CDN-Cache-Control": "s-maxage=300, stale-while-revalidate=3600", - }, - }); + return feedResponse( + req, + feed.json1(), + "application/feed+json", + feed.options.updated, + ); } diff --git a/app/(app)/lish/[did]/[publication]/layout.tsx b/app/(app)/lish/[did]/[publication]/layout.tsx index 66a4f20f..db23e947 100644 --- a/app/(app)/lish/[did]/[publication]/layout.tsx +++ b/app/(app)/lish/[did]/[publication]/layout.tsx @@ -22,12 +22,7 @@ export async function generateMetadata(props: { let publication_name = decodeURIComponent(params.publication); let { data: publications } = await supabaseServerClient .from("publications") - .select( - `*, - publication_subscriptions(*), - documents_in_publications(documents(*)) - `, - ) + .select(`uri, record`) .eq("identity_did", did) .or(publicationNameOrUriFilter(did, publication_name)) .order("uri", { ascending: false }) diff --git a/app/(app)/lish/[did]/[publication]/page.tsx b/app/(app)/lish/[did]/[publication]/page.tsx index fce44b40..1ab093ff 100644 --- a/app/(app)/lish/[did]/[publication]/page.tsx +++ b/app/(app)/lish/[did]/[publication]/page.tsx @@ -8,7 +8,10 @@ import { normalizePublicationRecord } from "src/utils/normalizeRecords"; import { publicationAlternates } from "./publicationAlternates"; import { DefaultPublicationHomepage } from "./DefaultPublicationHomepage"; import { buildPublicationPosts } from "./buildPublicationPosts"; -import { fetchPublicationForPage } from "./getPublicationForPage"; +import { + fetchPublicationForPage, + fetchPublicationPostRows, +} from "./getPublicationForPage"; import { tryRenderPublicationPage } from "./tryRenderPublicationPage"; import { getProfiles } from "src/identity"; import { attachBylineProfiles, bylineDidsForPosts } from "src/utils/byline"; @@ -62,14 +65,18 @@ export default async function Publication(props: { const record = normalizePublicationRecord(publication.record); // Resolve the author profile and post bylines server-side so they're in the - // SSR HTML. + // SSR HTML. The profile lookup is independent of the post queries, so it + // runs alongside the rows → bylines chain. const agent = new BskyAgent({ service: "https://public.api.bsky.app" }); - const homepagePosts = buildPublicationPosts( - publication.documents_in_publications, - ); - const [{ data: profile }, bylineProfiles] = await Promise.all([ + const [{ data: profile }, posts] = await Promise.all([ agent.getProfile({ actor: did }), - getProfiles(bylineDidsForPosts(homepagePosts)), + fetchPublicationPostRows(publication.uri).then(async (rows) => { + const posts = buildPublicationPosts(rows); + return attachBylineProfiles( + posts, + await getProfiles(bylineDidsForPosts(posts)), + ); + }), ]); return ( diff --git a/app/(app)/lish/[did]/[publication]/rss/route.ts b/app/(app)/lish/[did]/[publication]/rss/route.ts index 3f91c0a7..4a8fa596 100644 --- a/app/(app)/lish/[did]/[publication]/rss/route.ts +++ b/app/(app)/lish/[did]/[publication]/rss/route.ts @@ -21,5 +21,6 @@ export async function GET( req, collapseInterTagWhitespace(feed.rss2()), "application/rss+xml", + feed.options.updated, ); } diff --git a/app/(app)/lish/[did]/[publication]/theme-settings/PubPreview.tsx b/app/(app)/lish/[did]/[publication]/theme-settings/PubPreview.tsx index 11ecf3b2..1f660beb 100644 --- a/app/(app)/lish/[did]/[publication]/theme-settings/PubPreview.tsx +++ b/app/(app)/lish/[did]/[publication]/theme-settings/PubPreview.tsx @@ -7,6 +7,7 @@ import { import { useIdentityData } from "components/IdentityProvider"; import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; import { DefaultPublicationHomepage } from "../DefaultPublicationHomepage"; +import { buildPublicationPosts } from "../buildPublicationPosts"; import { LocalizedDate } from "../LocalizedDate"; import type { ReactNode } from "react"; @@ -100,6 +101,7 @@ export function PubPreview(props: { profile={profile} showPageBackground={props.showPageBackground} fakePosts={fakePosts} + posts={buildPublicationPosts(publication.documents_in_publications)} /> ); } diff --git a/app/(app)/lish/feeds/[...path]/route.ts b/app/(app)/lish/feeds/[...path]/route.ts index 02743e63..c36453e7 100644 --- a/app/(app)/lish/feeds/[...path]/route.ts +++ b/app/(app)/lish/feeds/[...path]/route.ts @@ -2,10 +2,6 @@ import { NextResponse } from "next/server"; import { DidResolver } from "@atproto/identity"; import { parseReqNsid, verifyJwt } from "@atproto/xrpc-server"; import { supabaseServerClient } from "supabase/serverClient"; -import { - normalizeDocumentRecord, - type NormalizedDocument, -} from "src/utils/normalizeRecords"; const serviceDid = "did:web:leaflet.pub:lish:feeds"; export async function GET( @@ -27,19 +23,29 @@ export async function GET( }); let auth = validateAuth(req, serviceDid); if (!auth) return NextResponse.json({}, { status: 301 }); + // The skeleton only needs the bsky post ref out of each document, so + // project just the refs instead of the full record jsonb. postRef is the + // pub.leaflet field, bskyPostRef the site.standard one; publishedAt gates + // pub.leaflet records the same way normalizeDocument does. let { data: publications } = await supabaseServerClient .from("publication_subscriptions") - .select(`publications(documents_in_publications(documents(*)))`) + .select( + `publications(documents_in_publications(documents( + postRef:data->postRef, bskyPostRef:data->bskyPostRef, publishedAt:data->>publishedAt)))`, + ) .eq("identity", auth); return NextResponse.json({ feed: [ ...(publications || []).flatMap((pub) => { let posts = pub.publications?.documents_in_publications || []; return posts.flatMap((p) => { - if (!p.documents?.data) return []; - const normalizedDoc = normalizeDocumentRecord(p.documents.data, p.documents.uri); - if (!normalizedDoc?.bskyPostRef) return []; - return { post: normalizedDoc.bskyPostRef.uri }; + if (!p.documents) return []; + let ref = (p.documents.bskyPostRef ?? + (p.documents.publishedAt ? p.documents.postRef : null)) as { + uri?: string; + } | null; + if (!ref?.uri) return []; + return { post: ref.uri }; }); }), ], -- 2.51.2