a tool for shared writing and social publishing

Merge branch 'main' of https://github.com/hyperlink-academy/minilink into update/thread-viewer

+579 -37
+1 -1
actions/publishToPublication.ts
··· 65 65 } from "src/utils/collectionHelpers"; 66 66 67 67 type PublishResult = 68 - | { success: true; rkey: string; record: PubLeafletDocument.Record } 68 + | { success: true; rkey: string; record: SiteStandardDocument.Record } 69 69 | { success: false; error: OAuthSessionError }; 70 70 71 71 export async function publishToPublication({
+1 -1
app/(home-pages)/discover/PubListing.tsx
··· 62 62 <p> 63 63 Updated{" "} 64 64 {timeAgo( 65 - props.documents_in_publications?.[0]?.documents?.indexed_at || 65 + props.documents_in_publications?.[0]?.documents?.sort_date || 66 66 "", 67 67 )} 68 68 </p>
+8 -8
app/(home-pages)/discover/getPublications.ts
··· 8 8 import { deduplicateByUri } from "src/utils/deduplicateRecords"; 9 9 10 10 export type Cursor = { 11 - indexed_at?: string; 11 + sort_date?: string; 12 12 count?: number; 13 13 uri: string; 14 14 }; ··· 32 32 .or( 33 33 "record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true", 34 34 ) 35 - .order("indexed_at", { 35 + .order("documents(sort_date)", { 36 36 referencedTable: "documents_in_publications", 37 37 ascending: false, 38 38 }) ··· 64 64 } else { 65 65 // recentlyUpdated 66 66 const aDate = new Date( 67 - a.documents_in_publications[0]?.indexed_at || 0, 67 + a.documents_in_publications[0]?.documents?.sort_date || 0, 68 68 ).getTime(); 69 69 const bDate = new Date( 70 - b.documents_in_publications[0]?.indexed_at || 0, 70 + b.documents_in_publications[0]?.documents?.sort_date || 0, 71 71 ).getTime(); 72 72 if (bDate !== aDate) { 73 73 return bDate - aDate; ··· 89 89 (pubCount === cursor.count && pub.uri < cursor.uri) 90 90 ); 91 91 } else { 92 - const pubDate = pub.documents_in_publications[0]?.indexed_at || ""; 92 + const pubDate = pub.documents_in_publications[0]?.documents?.sort_date || ""; 93 93 // Find first pub after cursor 94 94 return ( 95 - pubDate < (cursor.indexed_at || "") || 96 - (pubDate === cursor.indexed_at && pub.uri < cursor.uri) 95 + pubDate < (cursor.sort_date || "") || 96 + (pubDate === cursor.sort_date && pub.uri < cursor.uri) 97 97 ); 98 98 } 99 99 }); ··· 117 117 normalizedPage.length > 0 && startIndex + limit < allPubs.length 118 118 ? order === "recentlyUpdated" 119 119 ? { 120 - indexed_at: lastItem.documents_in_publications[0]?.indexed_at, 120 + sort_date: lastItem.documents_in_publications[0]?.documents?.sort_date, 121 121 uri: lastItem.uri, 122 122 } 123 123 : {
+5 -5
app/(home-pages)/p/[didOrHandle]/getProfilePosts.ts
··· 10 10 import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords"; 11 11 12 12 export type Cursor = { 13 - indexed_at: string; 13 + sort_date: string; 14 14 uri: string; 15 15 }; 16 16 ··· 29 29 documents_in_publications(publications(*))`, 30 30 ) 31 31 .like("uri", `at://${did}/%`) 32 - .order("indexed_at", { ascending: false }) 32 + .order("sort_date", { ascending: false }) 33 33 .order("uri", { ascending: false }) 34 34 .limit(limit); 35 35 36 36 if (cursor) { 37 37 query = query.or( 38 - `indexed_at.lt.${cursor.indexed_at},and(indexed_at.eq.${cursor.indexed_at},uri.lt.${cursor.uri})`, 38 + `sort_date.lt.${cursor.sort_date},and(sort_date.eq.${cursor.sort_date},uri.lt.${cursor.uri})`, 39 39 ); 40 40 } 41 41 ··· 79 79 documents: { 80 80 data: normalizedData, 81 81 uri: doc.uri, 82 - indexed_at: doc.indexed_at, 82 + sort_date: doc.sort_date, 83 83 comments_on_documents: doc.comments_on_documents, 84 84 document_mentions_in_bsky: doc.document_mentions_in_bsky, 85 85 }, ··· 99 99 const nextCursor = 100 100 posts.length === limit 101 101 ? { 102 - indexed_at: posts[posts.length - 1].documents.indexed_at, 102 + sort_date: posts[posts.length - 1].documents.sort_date, 103 103 uri: posts[posts.length - 1].documents.uri, 104 104 } 105 105 : null;
+5 -5
app/(home-pages)/reader/getReaderFeed.ts
··· 38 38 "documents_in_publications.publications.publication_subscriptions.identity", 39 39 auth_res.atp_did, 40 40 ) 41 - .order("indexed_at", { ascending: false }) 41 + .order("sort_date", { ascending: false }) 42 42 .order("uri", { ascending: false }) 43 43 .limit(25); 44 44 if (cursor) { 45 45 query = query.or( 46 - `indexed_at.lt.${cursor.timestamp},and(indexed_at.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, 46 + `sort_date.lt.${cursor.timestamp},and(sort_date.eq.${cursor.timestamp},uri.lt.${cursor.uri})`, 47 47 ); 48 48 } 49 49 let { data: rawFeed, error } = await query; ··· 78 78 document_mentions_in_bsky: post.document_mentions_in_bsky, 79 79 data: normalizedData, 80 80 uri: post.uri, 81 - indexed_at: post.indexed_at, 81 + sort_date: post.sort_date, 82 82 }, 83 83 }; 84 84 return p; ··· 88 88 const nextCursor = 89 89 posts.length > 0 90 90 ? { 91 - timestamp: posts[posts.length - 1].documents.indexed_at, 91 + timestamp: posts[posts.length - 1].documents.sort_date, 92 92 uri: posts[posts.length - 1].documents.uri, 93 93 } 94 94 : null; ··· 109 109 documents: { 110 110 data: NormalizedDocument | null; 111 111 uri: string; 112 - indexed_at: string; 112 + sort_date: string; 113 113 comments_on_documents: { count: number }[] | undefined; 114 114 document_mentions_in_bsky: { count: number }[] | undefined; 115 115 };
+2 -2
app/(home-pages)/reader/getSubscriptions.ts
··· 32 32 .select(`*, publications(*, documents_in_publications(*, documents(*)))`) 33 33 .order(`created_at`, { ascending: false }) 34 34 .order(`uri`, { ascending: false }) 35 - .order("indexed_at", { 35 + .order("documents(sort_date)", { 36 36 ascending: false, 37 37 referencedTable: "publications.documents_in_publications", 38 38 }) ··· 85 85 record: NormalizedPublication; 86 86 uri: string; 87 87 documents_in_publications: { 88 - documents: { data?: Json; indexed_at: string } | null; 88 + documents: { data?: Json; sort_date: string } | null; 89 89 }[]; 90 90 };
+2 -2
app/(home-pages)/tag/[tag]/getDocumentsByTag.ts
··· 24 24 documents_in_publications(publications(*))`, 25 25 ) 26 26 .contains("data->tags", `["${tag}"]`) 27 - .order("indexed_at", { ascending: false }) 27 + .order("sort_date", { ascending: false }) 28 28 .limit(50); 29 29 30 30 if (error) { ··· 69 69 document_mentions_in_bsky: doc.document_mentions_in_bsky, 70 70 data: normalizedData, 71 71 uri: doc.uri, 72 - indexed_at: doc.indexed_at, 72 + sort_date: doc.sort_date, 73 73 }, 74 74 }; 75 75 return post;
+4 -7
app/[leaflet_id]/publish/publishBskyPost.ts
··· 8 8 import sharp from "sharp"; 9 9 import { TID } from "@atproto/common"; 10 10 import { getIdentityData } from "actions/getIdentityData"; 11 - import { AtpBaseClient, PubLeafletDocument } from "lexicons/api"; 12 - import { 13 - restoreOAuthSession, 14 - OAuthSessionError, 15 - } from "src/atproto-oauth"; 11 + import { AtpBaseClient, SiteStandardDocument } from "lexicons/api"; 12 + import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth"; 16 13 import { supabaseServerClient } from "supabase/serverClient"; 17 14 import { Json } from "supabase/database.types"; 18 15 import { ··· 30 27 url: string; 31 28 title: string; 32 29 description: string; 33 - document_record: PubLeafletDocument.Record; 30 + document_record: SiteStandardDocument.Record; 34 31 rkey: string; 35 32 facets: AppBskyRichtextFacet.Main[]; 36 33 }): Promise<PublishBskyResult> { ··· 115 112 }, 116 113 ); 117 114 let record = args.document_record; 118 - record.postRef = post; 115 + record.bskyPostRef = post; 119 116 120 117 let { data: result } = await agent.com.atproto.repo.putRecord({ 121 118 rkey: args.rkey,
+10
app/api/inngest/client.ts
··· 41 41 documentUris: string[]; 42 42 }; 43 43 }; 44 + "documents/fix-incorrect-site-values": { 45 + data: { 46 + did: string; 47 + }; 48 + }; 49 + "documents/fix-postref": { 50 + data: { 51 + documentUris?: string[]; 52 + }; 53 + }; 44 54 }; 45 55 46 56 // Create a client to send and receive events
+300
app/api/inngest/functions/fix_incorrect_site_values.ts
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { inngest } from "../client"; 3 + import { restoreOAuthSession } from "src/atproto-oauth"; 4 + import { AtpBaseClient, SiteStandardDocument } from "lexicons/api"; 5 + import { AtUri } from "@atproto/syntax"; 6 + import { Json } from "supabase/database.types"; 7 + 8 + async function createAuthenticatedAgent(did: string): Promise<AtpBaseClient> { 9 + const result = await restoreOAuthSession(did); 10 + if (!result.ok) { 11 + throw new Error(`Failed to restore OAuth session: ${result.error.message}`); 12 + } 13 + const credentialSession = result.value; 14 + return new AtpBaseClient( 15 + credentialSession.fetchHandler.bind(credentialSession), 16 + ); 17 + } 18 + 19 + /** 20 + * Build set of valid site values for a publication. 21 + * A site value is valid if it matches the publication or its legacy equivalent. 22 + */ 23 + function buildValidSiteValues(pubUri: string): Set<string> { 24 + const validValues = new Set<string>([pubUri]); 25 + 26 + try { 27 + const aturi = new AtUri(pubUri); 28 + 29 + if (pubUri.includes("/site.standard.publication/")) { 30 + // Also accept legacy pub.leaflet.publication 31 + validValues.add( 32 + `at://${aturi.hostname}/pub.leaflet.publication/${aturi.rkey}`, 33 + ); 34 + } else if (pubUri.includes("/pub.leaflet.publication/")) { 35 + // Also accept new site.standard.publication 36 + validValues.add( 37 + `at://${aturi.hostname}/site.standard.publication/${aturi.rkey}`, 38 + ); 39 + } 40 + } catch (e) { 41 + // Invalid URI, just use the original 42 + } 43 + 44 + return validValues; 45 + } 46 + 47 + /** 48 + * This function finds and fixes documents that have incorrect site values. 49 + * A document has an incorrect site value if its `site` field doesn't match 50 + * the publication it belongs to (via documents_in_publications). 51 + * 52 + * Takes a DID as input and processes publications owned by that identity. 53 + */ 54 + export const fix_incorrect_site_values = inngest.createFunction( 55 + { id: "fix_incorrect_site_values" }, 56 + { event: "documents/fix-incorrect-site-values" }, 57 + async ({ event, step }) => { 58 + const { did } = event.data; 59 + 60 + const stats = { 61 + publicationsChecked: 0, 62 + documentsChecked: 0, 63 + documentsWithIncorrectSite: 0, 64 + documentsFixed: 0, 65 + documentsMissingSite: 0, 66 + errors: [] as string[], 67 + }; 68 + 69 + // Step 1: Get all publications owned by this identity 70 + const publications = await step.run("fetch-publications", async () => { 71 + const { data, error } = await supabaseServerClient 72 + .from("publications") 73 + .select("uri") 74 + .eq("identity_did", did); 75 + 76 + if (error) { 77 + throw new Error(`Failed to fetch publications: ${error.message}`); 78 + } 79 + return data || []; 80 + }); 81 + 82 + stats.publicationsChecked = publications.length; 83 + 84 + if (publications.length === 0) { 85 + return { 86 + success: true, 87 + message: "No publications found for this identity", 88 + stats, 89 + }; 90 + } 91 + 92 + // Step 2: Get all documents_in_publications entries for these publications 93 + const publicationUris = publications.map((p) => p.uri); 94 + 95 + const joinEntries = await step.run( 96 + "fetch-documents-in-publications", 97 + async () => { 98 + const { data, error } = await supabaseServerClient 99 + .from("documents_in_publications") 100 + .select("document, publication") 101 + .in("publication", publicationUris); 102 + 103 + if (error) { 104 + throw new Error( 105 + `Failed to fetch documents_in_publications: ${error.message}`, 106 + ); 107 + } 108 + return data || []; 109 + }, 110 + ); 111 + 112 + if (joinEntries.length === 0) { 113 + return { 114 + success: true, 115 + message: "No documents found in publications", 116 + stats, 117 + }; 118 + } 119 + 120 + // Create a map of document URI -> expected publication URI 121 + const documentToPublication = new Map<string, string>(); 122 + for (const row of joinEntries) { 123 + documentToPublication.set(row.document, row.publication); 124 + } 125 + 126 + // Step 3: Fetch all document records 127 + const documentUris = Array.from(documentToPublication.keys()); 128 + 129 + const allDocuments = await step.run("fetch-documents", async () => { 130 + const { data, error } = await supabaseServerClient 131 + .from("documents") 132 + .select("uri, data") 133 + .in("uri", documentUris); 134 + 135 + if (error) { 136 + throw new Error(`Failed to fetch documents: ${error.message}`); 137 + } 138 + return data || []; 139 + }); 140 + 141 + stats.documentsChecked = allDocuments.length; 142 + 143 + // Step 4: Find documents with incorrect site values 144 + const documentsToFix: Array<{ 145 + uri: string; 146 + currentSite: string | null; 147 + correctSite: string; 148 + docData: SiteStandardDocument.Record; 149 + }> = []; 150 + 151 + for (const doc of allDocuments) { 152 + const expectedPubUri = documentToPublication.get(doc.uri); 153 + if (!expectedPubUri) continue; 154 + 155 + const data = doc.data as unknown as SiteStandardDocument.Record; 156 + const currentSite = data?.site; 157 + 158 + if (!currentSite) { 159 + stats.documentsMissingSite++; 160 + continue; 161 + } 162 + 163 + const validSiteValues = buildValidSiteValues(expectedPubUri); 164 + 165 + if (!validSiteValues.has(currentSite)) { 166 + // Document has incorrect site value - determine the correct one 167 + // Prefer the site.standard.publication format if the doc is site.standard.document 168 + let correctSite = expectedPubUri; 169 + 170 + if (doc.uri.includes("/site.standard.document/")) { 171 + // For site.standard.document, use site.standard.publication format 172 + try { 173 + const pubAturi = new AtUri(expectedPubUri); 174 + if (expectedPubUri.includes("/pub.leaflet.publication/")) { 175 + correctSite = `at://${pubAturi.hostname}/site.standard.publication/${pubAturi.rkey}`; 176 + } 177 + } catch (e) { 178 + // Use as-is 179 + } 180 + } 181 + 182 + documentsToFix.push({ 183 + uri: doc.uri, 184 + currentSite, 185 + correctSite, 186 + docData: data, 187 + }); 188 + } 189 + } 190 + 191 + stats.documentsWithIncorrectSite = documentsToFix.length; 192 + 193 + if (documentsToFix.length === 0) { 194 + return { 195 + success: true, 196 + message: "All documents have correct site values", 197 + stats, 198 + }; 199 + } 200 + 201 + // Step 5: Group documents by author DID for efficient OAuth session handling 202 + const docsByDid = new Map<string, typeof documentsToFix>(); 203 + for (const doc of documentsToFix) { 204 + try { 205 + const aturi = new AtUri(doc.uri); 206 + const authorDid = aturi.hostname; 207 + const existing = docsByDid.get(authorDid) || []; 208 + existing.push(doc); 209 + docsByDid.set(authorDid, existing); 210 + } catch (e) { 211 + stats.errors.push(`Invalid URI: ${doc.uri}`); 212 + } 213 + } 214 + 215 + // Step 6: Process each author's documents 216 + for (const [authorDid, docs] of docsByDid) { 217 + // Verify OAuth session for this author 218 + const oauthValid = await step.run( 219 + `verify-oauth-${authorDid.slice(-8)}`, 220 + async () => { 221 + const result = await restoreOAuthSession(authorDid); 222 + return result.ok; 223 + }, 224 + ); 225 + 226 + if (!oauthValid) { 227 + stats.errors.push(`No valid OAuth session for ${authorDid}`); 228 + continue; 229 + } 230 + 231 + // Fix each document for this author 232 + for (const docToFix of docs) { 233 + const result = await step.run( 234 + `fix-doc-${docToFix.uri.slice(-12)}`, 235 + async () => { 236 + try { 237 + const docAturi = new AtUri(docToFix.uri); 238 + 239 + // Build updated record 240 + const updatedRecord: SiteStandardDocument.Record = { 241 + ...docToFix.docData, 242 + site: docToFix.correctSite, 243 + }; 244 + 245 + // Update on PDS 246 + const agent = await createAuthenticatedAgent(authorDid); 247 + await agent.com.atproto.repo.putRecord({ 248 + repo: authorDid, 249 + collection: docAturi.collection, 250 + rkey: docAturi.rkey, 251 + record: updatedRecord, 252 + validate: false, 253 + }); 254 + 255 + // Update in database 256 + const { error: dbError } = await supabaseServerClient 257 + .from("documents") 258 + .update({ data: updatedRecord as Json }) 259 + .eq("uri", docToFix.uri); 260 + 261 + if (dbError) { 262 + return { 263 + success: false as const, 264 + error: `Database update failed: ${dbError.message}`, 265 + }; 266 + } 267 + 268 + return { 269 + success: true as const, 270 + oldSite: docToFix.currentSite, 271 + newSite: docToFix.correctSite, 272 + }; 273 + } catch (e) { 274 + return { 275 + success: false as const, 276 + error: e instanceof Error ? e.message : String(e), 277 + }; 278 + } 279 + }, 280 + ); 281 + 282 + if (result.success) { 283 + stats.documentsFixed++; 284 + } else { 285 + stats.errors.push(`${docToFix.uri}: ${result.error}`); 286 + } 287 + } 288 + } 289 + 290 + return { 291 + success: stats.errors.length === 0, 292 + stats, 293 + documentsToFix: documentsToFix.map((d) => ({ 294 + uri: d.uri, 295 + oldSite: d.currentSite, 296 + newSite: d.correctSite, 297 + })), 298 + }; 299 + }, 300 + );
+196
app/api/inngest/functions/fix_standard_document_postref.ts
··· 1 + import { supabaseServerClient } from "supabase/serverClient"; 2 + import { inngest } from "../client"; 3 + import { restoreOAuthSession } from "src/atproto-oauth"; 4 + import { 5 + AtpBaseClient, 6 + SiteStandardDocument, 7 + ComAtprotoRepoStrongRef, 8 + } from "lexicons/api"; 9 + import { AtUri } from "@atproto/syntax"; 10 + import { Json } from "supabase/database.types"; 11 + 12 + async function createAuthenticatedAgent(did: string): Promise<AtpBaseClient> { 13 + const result = await restoreOAuthSession(did); 14 + if (!result.ok) { 15 + throw new Error(`Failed to restore OAuth session: ${result.error.message}`); 16 + } 17 + const credentialSession = result.value; 18 + return new AtpBaseClient( 19 + credentialSession.fetchHandler.bind(credentialSession), 20 + ); 21 + } 22 + 23 + /** 24 + * Fixes site.standard.document records that have the legacy `postRef` field set. 25 + * Migrates the value to `bskyPostRef` (the correct field for site.standard.document) 26 + * and removes the legacy `postRef` field. 27 + * 28 + * Can be triggered with specific document URIs, or will find all affected documents 29 + * if no URIs are provided. 30 + */ 31 + export const fix_standard_document_postref = inngest.createFunction( 32 + { id: "fix_standard_document_postref" }, 33 + { event: "documents/fix-postref" }, 34 + async ({ event, step }) => { 35 + const { documentUris: providedUris } = event.data as { 36 + documentUris?: string[]; 37 + }; 38 + 39 + const stats = { 40 + documentsFound: 0, 41 + documentsFixed: 0, 42 + documentsSkipped: 0, 43 + errors: [] as string[], 44 + }; 45 + 46 + // Step 1: Find documents to fix (either provided or query for them) 47 + const documentUris = await step.run("find-documents", async () => { 48 + if (providedUris && providedUris.length > 0) { 49 + return providedUris; 50 + } 51 + 52 + // Find all site.standard.document records with postRef set 53 + const { data: documents, error } = await supabaseServerClient 54 + .from("documents") 55 + .select("uri") 56 + .like("uri", "at://%/site.standard.document/%") 57 + .not("data->postRef", "is", null); 58 + 59 + if (error) { 60 + throw new Error(`Failed to query documents: ${error.message}`); 61 + } 62 + 63 + return (documents || []).map((d) => d.uri); 64 + }); 65 + 66 + stats.documentsFound = documentUris.length; 67 + 68 + if (documentUris.length === 0) { 69 + return { 70 + success: true, 71 + message: "No documents found with postRef field", 72 + stats, 73 + }; 74 + } 75 + 76 + // Step 2: Group documents by DID for efficient OAuth session handling 77 + const docsByDid = new Map<string, string[]>(); 78 + for (const uri of documentUris) { 79 + try { 80 + const aturi = new AtUri(uri); 81 + const did = aturi.hostname; 82 + const existing = docsByDid.get(did) || []; 83 + existing.push(uri); 84 + docsByDid.set(did, existing); 85 + } catch (e) { 86 + stats.errors.push(`Invalid URI: ${uri}`); 87 + } 88 + } 89 + 90 + // Step 3: Process each DID's documents 91 + for (const [did, uris] of docsByDid) { 92 + // Verify OAuth session for this user 93 + const oauthValid = await step.run( 94 + `verify-oauth-${did.slice(-8)}`, 95 + async () => { 96 + const result = await restoreOAuthSession(did); 97 + return result.ok; 98 + }, 99 + ); 100 + 101 + if (!oauthValid) { 102 + stats.errors.push(`No valid OAuth session for ${did}`); 103 + stats.documentsSkipped += uris.length; 104 + continue; 105 + } 106 + 107 + // Fix each document 108 + for (const docUri of uris) { 109 + const result = await step.run( 110 + `fix-doc-${docUri.slice(-12)}`, 111 + async () => { 112 + // Fetch the document 113 + const { data: doc, error: fetchError } = await supabaseServerClient 114 + .from("documents") 115 + .select("uri, data") 116 + .eq("uri", docUri) 117 + .single(); 118 + 119 + if (fetchError || !doc) { 120 + return { 121 + success: false as const, 122 + error: `Document not found: ${fetchError?.message || "no data"}`, 123 + }; 124 + } 125 + 126 + const data = doc.data as Record<string, unknown>; 127 + const postRef = data.postRef as 128 + | ComAtprotoRepoStrongRef.Main 129 + | undefined; 130 + 131 + if (!postRef) { 132 + return { 133 + success: false as const, 134 + skipped: true, 135 + error: "Document does not have postRef field", 136 + }; 137 + } 138 + 139 + // Build updated record: move postRef to bskyPostRef 140 + const { postRef: _, ...restData } = data; 141 + let updatedRecord: SiteStandardDocument.Record = { 142 + ...(restData as SiteStandardDocument.Record), 143 + }; 144 + 145 + updatedRecord.bskyPostRef = data.bskyPostRef 146 + ? (data.bskyPostRef as ComAtprotoRepoStrongRef.Main) 147 + : postRef; 148 + 149 + // Write to PDS 150 + const docAturi = new AtUri(docUri); 151 + const agent = await createAuthenticatedAgent(did); 152 + await agent.com.atproto.repo.putRecord({ 153 + repo: did, 154 + collection: "site.standard.document", 155 + rkey: docAturi.rkey, 156 + record: updatedRecord, 157 + validate: false, 158 + }); 159 + 160 + // Update database 161 + const { error: dbError } = await supabaseServerClient 162 + .from("documents") 163 + .update({ data: updatedRecord as Json }) 164 + .eq("uri", docUri); 165 + 166 + if (dbError) { 167 + return { 168 + success: false as const, 169 + error: `Database update failed: ${dbError.message}`, 170 + }; 171 + } 172 + 173 + return { 174 + success: true as const, 175 + postRef, 176 + bskyPostRef: updatedRecord.bskyPostRef, 177 + }; 178 + }, 179 + ); 180 + 181 + if (result.success) { 182 + stats.documentsFixed++; 183 + } else if ("skipped" in result && result.skipped) { 184 + stats.documentsSkipped++; 185 + } else { 186 + stats.errors.push(`${docUri}: ${result.error}`); 187 + } 188 + } 189 + } 190 + 191 + return { 192 + success: stats.errors.length === 0, 193 + stats, 194 + }; 195 + }, 196 + );
+4
app/api/inngest/route.tsx
··· 6 6 import { index_follows } from "./functions/index_follows"; 7 7 import { migrate_user_to_standard } from "./functions/migrate_user_to_standard"; 8 8 import { fix_standard_document_publications } from "./functions/fix_standard_document_publications"; 9 + import { fix_incorrect_site_values } from "./functions/fix_incorrect_site_values"; 10 + import { fix_standard_document_postref } from "./functions/fix_standard_document_postref"; 9 11 import { 10 12 cleanup_expired_oauth_sessions, 11 13 check_oauth_session, ··· 20 22 index_follows, 21 23 migrate_user_to_standard, 22 24 fix_standard_document_publications, 25 + fix_incorrect_site_values, 26 + fix_standard_document_postref, 23 27 cleanup_expired_oauth_sessions, 24 28 check_oauth_session, 25 29 ],
+1
app/api/rpc/[command]/get_publication_data.ts
··· 83 83 uri: dip.documents.uri, 84 84 record: normalized, 85 85 indexed_at: dip.documents.indexed_at, 86 + sort_date: dip.documents.sort_date, 86 87 data: dip.documents.data, 87 88 commentsCount: dip.documents.comments_on_documents[0]?.count || 0, 88 89 mentionsCount: dip.documents.document_mentions_in_bsky[0]?.count || 0,
+1
app/lish/[did]/[publication]/dashboard/PublishedPostsLists.tsx
··· 111 111 documents: { 112 112 uri: doc.uri, 113 113 indexed_at: doc.indexed_at, 114 + sort_date: doc.sort_date, 114 115 data: doc.data, 115 116 }, 116 117 },
+7 -3
app/lish/createPub/getPublicationURL.ts
··· 25 25 } 26 26 27 27 // Fall back to checking raw record for legacy base_path 28 - if (isLeafletPublication(pub.record) && pub.record.base_path && isProductionDomain()) { 28 + if ( 29 + isLeafletPublication(pub.record) && 30 + pub.record.base_path && 31 + isProductionDomain() 32 + ) { 29 33 return `https://${pub.record.base_path}`; 30 34 } 31 35 ··· 36 40 const normalized = normalizePublicationRecord(pub.record); 37 41 const aturi = new AtUri(pub.uri); 38 42 39 - // Use normalized name if available, fall back to rkey 40 - const name = normalized?.name || aturi.rkey; 43 + //use rkey, fallback to name 44 + const name = aturi.rkey || normalized?.name; 41 45 return `/lish/${aturi.host}/${encodeURIComponent(name || "")}`; 42 46 }
+3 -3
feeds/index.ts
··· 116 116 } 117 117 query = query 118 118 .or("data->postRef.not.is.null,data->bskyPostRef.not.is.null") 119 - .order("indexed_at", { ascending: false }) 119 + .order("sort_date", { ascending: false }) 120 120 .order("uri", { ascending: false }) 121 121 .limit(25); 122 122 if (parsedCursor) 123 123 query = query.or( 124 - `indexed_at.lt.${parsedCursor.date},and(indexed_at.eq.${parsedCursor.date},uri.lt.${parsedCursor.uri})`, 124 + `sort_date.lt.${parsedCursor.date},and(sort_date.eq.${parsedCursor.date},uri.lt.${parsedCursor.uri})`, 125 125 ); 126 126 127 127 let { data, error } = await query; ··· 131 131 posts = posts || []; 132 132 133 133 let lastPost = posts[posts.length - 1]; 134 - let newCursor = lastPost ? `${lastPost.indexed_at}::${lastPost.uri}` : null; 134 + let newCursor = lastPost ? `${lastPost.sort_date}::${lastPost.uri}` : null; 135 135 return c.json({ 136 136 cursor: newCursor || cursor, 137 137 feed: posts.flatMap((p) => {
+1
supabase/database.types.ts
··· 337 337 Row: { 338 338 data: Json 339 339 indexed_at: string 340 + sort_date: string 340 341 uri: string 341 342 } 342 343 Insert: {
+28
supabase/migrations/20260125000000_add_sort_date_column.sql
··· 1 + -- Add sort_date computed column to documents table 2 + -- This column stores the older of publishedAt (from JSON data) or indexed_at 3 + -- Used for sorting feeds chronologically by when content was actually published 4 + 5 + -- Create an immutable function to parse ISO 8601 timestamps from text 6 + -- This is needed because direct ::timestamp cast is not immutable (accepts 'now', 'today', etc.) 7 + -- The regex validates the format before casting to ensure immutability 8 + CREATE OR REPLACE FUNCTION parse_iso_timestamp(text) RETURNS timestamptz 9 + LANGUAGE sql IMMUTABLE STRICT AS $$ 10 + SELECT CASE 11 + -- Match ISO 8601 format: YYYY-MM-DDTHH:MM:SS with optional fractional seconds and Z/timezone 12 + WHEN $1 ~ '^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$' THEN 13 + $1::timestamptz 14 + ELSE 15 + NULL 16 + END 17 + $$; 18 + 19 + ALTER TABLE documents 20 + ADD COLUMN sort_date timestamptz GENERATED ALWAYS AS ( 21 + LEAST( 22 + COALESCE(parse_iso_timestamp(data->>'publishedAt'), indexed_at), 23 + indexed_at 24 + ) 25 + ) STORED; 26 + 27 + -- Create index on sort_date for efficient ordering 28 + CREATE INDEX documents_sort_date_idx ON documents (sort_date DESC, uri DESC);