From 7789c82a2133a0b5ae2ba8f92c24f4f019893067 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 4 Feb 2026 11:50:10 -0600 Subject: [PATCH 1/9] Add post recovery on ambiguous errors --- lib/constants.ts | 7 ++- lib/retry-utils.ts | 51 ++++++++++------ lib/services/post-service.ts | 110 +++++++++++++++++++++++++++++++++- lib/services/reply-service.ts | 102 ++++++++++++++++++++++++++++++- 4 files changed, 247 insertions(+), 23 deletions(-) diff --git a/lib/constants.ts b/lib/constants.ts index 058c1268..75dafbef 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -27,6 +27,11 @@ export const INSIGHT_API_CONFIG = { timeoutMs: 120000 } as const +// Post creation recovery (ambiguous errors) +export const POST_RECOVERY_LOOKBACK_MS = 2 * 60 * 1000 +export const POST_RECOVERY_POLL_ATTEMPTS = 3 +export const POST_RECOVERY_POLL_DELAY_MS = 2000 + // Document types // Note: AVATAR, REPOST, DIRECT_MESSAGE, NOTIFICATION were removed in contract migration // - avatar: now in unified profile contract @@ -68,4 +73,4 @@ export const STOREFRONT_DOCUMENT_TYPES = { } as const // DPNS -export const DPNS_DOCUMENT_TYPE = 'domain' \ No newline at end of file +export const DPNS_DOCUMENT_TYPE = 'domain' diff --git a/lib/retry-utils.ts b/lib/retry-utils.ts index 474d629c..3c104c35 100644 --- a/lib/retry-utils.ts +++ b/lib/retry-utils.ts @@ -49,6 +49,37 @@ function defaultRetryCondition(error: unknown): boolean { ) } +function getErrorMessage(error: unknown): string { + if (!error) return '' + if (error instanceof Error) return error.message + if (typeof error === 'string') return error + if (typeof error === 'object') { + const errObj = error as { message?: string } + if (typeof errObj.message === 'string') return errObj.message + } + return String(error) +} + +/** + * Check if a post creation error is ambiguous and might have actually succeeded. + * Includes network/timeouts and Dash Platform specific retryable errors. + */ +export function isPostCreationAmbiguousError(error: unknown): boolean { + if (defaultRetryCondition(error)) return true + + const errorMessage = getErrorMessage(error).toLowerCase() + + const dashErrors = [ + 'internal error', + 'temporarily unavailable', + 'service unavailable', + 'consensus error', + 'quorum not available' + ] + + return dashErrors.some(dashError => errorMessage.includes(dashError)) +} + /** * Exponential backoff with jitter */ @@ -130,23 +161,7 @@ export async function retryPostCreation( initialDelayMs: 2000, maxDelayMs: 8000, backoffMultiplier: 2, - retryCondition: (error) => { - // Use default retry condition plus Dash Platform specific errors - if (defaultRetryCondition(error)) return true - - const errorMessage = error instanceof Error ? error.message.toLowerCase() : '' - - // Dash Platform specific retryable errors - const dashErrors = [ - 'internal error', - 'temporarily unavailable', - 'service unavailable', - 'consensus error', - 'quorum not available' - ] - - return dashErrors.some(dashError => errorMessage.includes(dashError)) - }, + retryCondition: isPostCreationAmbiguousError, ...options }) } @@ -163,4 +178,4 @@ export function isNetworkError(error: unknown): boolean { */ export function isRetryableError(error: unknown): boolean { return defaultRetryCondition(error) -} \ No newline at end of file +} diff --git a/lib/services/post-service.ts b/lib/services/post-service.ts index 8659569d..43608a83 100644 --- a/lib/services/post-service.ts +++ b/lib/services/post-service.ts @@ -7,7 +7,8 @@ import { unifiedProfileService } from './unified-profile-service'; import { identifierToBase58, normalizeSDKResponse, RequestDeduplicator, stringToIdentifierBytes, normalizeBytes, getCurrentUserId as getSessionUserId, createDefaultUser, type DocumentWhereClause } from './sdk-helpers'; import type { DocumentsQuery } from '@dashevo/wasm-sdk'; import { seedBlockStatusCache, seedFollowStatusCache } from '../caches/user-status-cache'; -import { retryAsync } from '../retry-utils'; +import { retryAsync, isPostCreationAmbiguousError } from '../retry-utils'; +import { POST_RECOVERY_LOOKBACK_MS, POST_RECOVERY_POLL_ATTEMPTS, POST_RECOVERY_POLL_DELAY_MS } from '../constants'; import { paginateCount } from './pagination-utils'; export interface PostDocument { @@ -51,6 +52,15 @@ export interface PostStats { views: number; } +type ExpectedPostMatch = { + content: string; + quotedPostId?: string; + quotedPostOwnerId?: string; + encryptedContent?: Uint8Array; + nonce?: Uint8Array; + epoch?: number; +}; + class PostService extends BaseDocumentService { private statsCache: Map = new Map(); @@ -374,7 +384,101 @@ class PostService extends BaseDocumentService { if (options.primaryHashtag) data.primaryHashtag = options.primaryHashtag; if (options.sensitive !== undefined) data.sensitive = options.sensitive; - return this.create(ownerId, data); + const attemptStartedAt = Date.now(); + const expected: ExpectedPostMatch = { + content: data.content as string, + quotedPostId: options.quotedPostId, + quotedPostOwnerId: options.quotedPostOwnerId, + encryptedContent: data.encryptedContent as Uint8Array | undefined, + nonce: data.nonce as Uint8Array | undefined, + epoch: data.epoch as number | undefined, + }; + + try { + return await this.create(ownerId, data); + } catch (error) { + if (isPostCreationAmbiguousError(error)) { + const recovered = await this.recoverRecentPostMatch(ownerId, expected, attemptStartedAt); + if (recovered) { + console.warn('Post recovery succeeded after ambiguous error:', recovered.id); + this.clearCache(); + return recovered; + } + console.warn('Post recovery failed after ambiguous error; rethrowing original error'); + } + throw error; + } + } + + private async recoverRecentPostMatch( + ownerId: string, + expected: ExpectedPostMatch, + attemptStartedAt: number + ): Promise { + const minCreatedAt = attemptStartedAt - POST_RECOVERY_LOOKBACK_MS; + const isPrivateExpected = !!expected.encryptedContent && !!expected.nonce && typeof expected.epoch === 'number'; + + for (let attempt = 1; attempt <= POST_RECOVERY_POLL_ATTEMPTS; attempt++) { + try { + const result = await this.query({ + where: [ + ['$ownerId', '==', ownerId], + ['$createdAt', '>', minCreatedAt] + ], + orderBy: [['$ownerId', 'asc'], ['$createdAt', 'desc']], + limit: 20 + }); + + const match = result.documents.find((post) => + this.matchesExpectedPost(post, expected, isPrivateExpected) + ); + + if (match) { + return match; + } + } catch (error) { + console.warn(`Post recovery query failed (attempt ${attempt}/${POST_RECOVERY_POLL_ATTEMPTS}):`, error); + } + + if (attempt < POST_RECOVERY_POLL_ATTEMPTS) { + await this.sleep(POST_RECOVERY_POLL_DELAY_MS); + } + } + + return null; + } + + private matchesExpectedPost(post: Post, expected: ExpectedPostMatch, isPrivateExpected: boolean): boolean { + if (isPrivateExpected) { + return !!post.encryptedContent && + !!post.nonce && + typeof post.epoch === 'number' && + this.bytesEqual(expected.encryptedContent, post.encryptedContent) && + this.bytesEqual(expected.nonce, post.nonce) && + expected.epoch === post.epoch; + } + + const expectedQuotedId = expected.quotedPostId ?? null; + const expectedQuotedOwnerId = expected.quotedPostOwnerId ?? null; + const actualQuotedId = post.quotedPostId ?? null; + const actualQuotedOwnerId = post.quotedPostOwnerId ?? null; + + return post.content === expected.content && + expectedQuotedId === actualQuotedId && + expectedQuotedOwnerId === actualQuotedOwnerId; + } + + private bytesEqual(a?: Uint8Array, b?: Uint8Array): boolean { + if (!a || !b) return false; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; + } + + private async sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); } /** @@ -1209,4 +1313,4 @@ export type { EncryptionSource } from './reply-service'; export { getEncryptionSource } from './reply-service'; // Singleton instance -export const postService = new PostService(); \ No newline at end of file +export const postService = new PostService(); diff --git a/lib/services/reply-service.ts b/lib/services/reply-service.ts index 497724bd..ce45ac55 100644 --- a/lib/services/reply-service.ts +++ b/lib/services/reply-service.ts @@ -4,6 +4,8 @@ import { dpnsService } from './dpns-service'; import { unifiedProfileService } from './unified-profile-service'; import { identifierToBase58, normalizeSDKResponse, RequestDeduplicator, stringToIdentifierBytes, normalizeBytes, createDefaultUser } from './sdk-helpers'; import type { EncryptionOptions } from './post-service'; +import { isPostCreationAmbiguousError } from '../retry-utils'; +import { POST_RECOVERY_LOOKBACK_MS, POST_RECOVERY_POLL_ATTEMPTS, POST_RECOVERY_POLL_DELAY_MS } from '../constants'; export interface ReplyDocument { $id: string; @@ -30,6 +32,15 @@ export interface EncryptionSource { inherited: boolean; // True if encryption is inherited from parent } +type ExpectedReplyMatch = { + content: string; + parentId: string; + parentOwnerId: string; + encryptedContent?: Uint8Array; + nonce?: Uint8Array; + epoch?: number; +}; + class ReplyService extends BaseDocumentService { // Request deduplicators for batch operations private repliesDeduplicator = new RequestDeduplicator>(); @@ -186,7 +197,96 @@ class ReplyService extends BaseDocumentService { if (options.mediaUrl) data.mediaUrl = options.mediaUrl; if (options.sensitive !== undefined) data.sensitive = options.sensitive; - return this.create(ownerId, data); + const attemptStartedAt = Date.now(); + const expected: ExpectedReplyMatch = { + content: data.content as string, + parentId, + parentOwnerId, + encryptedContent: data.encryptedContent as Uint8Array | undefined, + nonce: data.nonce as Uint8Array | undefined, + epoch: data.epoch as number | undefined, + }; + + try { + return await this.create(ownerId, data); + } catch (error) { + if (isPostCreationAmbiguousError(error)) { + const recovered = await this.recoverRecentReplyMatch(ownerId, expected, attemptStartedAt); + if (recovered) { + console.warn('Reply recovery succeeded after ambiguous error:', recovered.id); + this.clearCache(); + return recovered; + } + console.warn('Reply recovery failed after ambiguous error; rethrowing original error'); + } + throw error; + } + } + + private async recoverRecentReplyMatch( + ownerId: string, + expected: ExpectedReplyMatch, + attemptStartedAt: number + ): Promise { + const minCreatedAt = attemptStartedAt - POST_RECOVERY_LOOKBACK_MS; + const isPrivateExpected = !!expected.encryptedContent && !!expected.nonce && typeof expected.epoch === 'number'; + + for (let attempt = 1; attempt <= POST_RECOVERY_POLL_ATTEMPTS; attempt++) { + try { + const result = await this.query({ + where: [ + ['$ownerId', '==', ownerId], + ['$createdAt', '>', minCreatedAt] + ], + orderBy: [['$ownerId', 'asc'], ['$createdAt', 'desc']], + limit: 20 + }); + + const match = result.documents.find((reply) => + this.matchesExpectedReply(reply, expected, isPrivateExpected) + ); + + if (match) { + return match; + } + } catch (error) { + console.warn(`Reply recovery query failed (attempt ${attempt}/${POST_RECOVERY_POLL_ATTEMPTS}):`, error); + } + + if (attempt < POST_RECOVERY_POLL_ATTEMPTS) { + await this.sleep(POST_RECOVERY_POLL_DELAY_MS); + } + } + + return null; + } + + private matchesExpectedReply(reply: Reply, expected: ExpectedReplyMatch, isPrivateExpected: boolean): boolean { + if (isPrivateExpected) { + return !!reply.encryptedContent && + !!reply.nonce && + typeof reply.epoch === 'number' && + this.bytesEqual(expected.encryptedContent, reply.encryptedContent) && + this.bytesEqual(expected.nonce, reply.nonce) && + expected.epoch === reply.epoch; + } + + return reply.content === expected.content && + reply.parentId === expected.parentId && + reply.parentOwnerId === expected.parentOwnerId; + } + + private bytesEqual(a?: Uint8Array, b?: Uint8Array): boolean { + if (!a || !b) return false; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; + } + + private async sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); } /** From 934f072c1963789b089ac07869ae1ab3e2147911 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 4 Feb 2026 11:59:58 -0600 Subject: [PATCH 2/9] Treat Tenderdash unavailable as ambiguous post error --- lib/retry-utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/retry-utils.ts b/lib/retry-utils.ts index 3c104c35..a3deebce 100644 --- a/lib/retry-utils.ts +++ b/lib/retry-utils.ts @@ -74,7 +74,9 @@ export function isPostCreationAmbiguousError(error: unknown): boolean { 'temporarily unavailable', 'service unavailable', 'consensus error', - 'quorum not available' + 'quorum not available', + 'tenderdash is not available', + 'tenderdash not available' ] return dashErrors.some(dashError => errorMessage.includes(dashError)) From cdc2813c6bc8dc0fcb29b9a2787e78972fd74f33 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 4 Feb 2026 12:11:03 -0600 Subject: [PATCH 3/9] Warn on recent duplicate posts --- components/compose/compose-modal.tsx | 143 +++++++++++++++++++++++++++ lib/constants.ts | 3 + 2 files changed, 146 insertions(+) diff --git a/components/compose/compose-modal.tsx b/components/compose/compose-modal.tsx index 8ba8b8d8..161fc941 100644 --- a/components/compose/compose-modal.tsx +++ b/components/compose/compose-modal.tsx @@ -46,6 +46,7 @@ import { ImageAttachment } from './image-attachment' import { StorageProviderModal } from './storage-provider-modal' import { useImageUpload } from '@/hooks/use-image-upload' import type { UploadResult } from '@/lib/upload' +import { POST_DUPLICATE_LOOKBACK_MS } from '@/lib/constants' export function ComposeModal() { const { @@ -76,6 +77,7 @@ export function ComposeModal() { const [showPreview, setShowPreview] = useState(false) const firstTextareaRef = useRef(null) const teaserTextareaRef = useRef(null) + const duplicateOverrideRef = useRef>(new Map()) // Private feed state const [hasPrivateFeed, setHasPrivateFeed] = useState(false) @@ -461,6 +463,91 @@ export function ComposeModal() { setAttachedImage({ file, preview }) }, [willBeEncrypted, attachedImage, isProviderConnected]) + const isDuplicateOverrideActive = (signature: string): boolean => { + const lastOverride = duplicateOverrideRef.current.get(signature) + if (!lastOverride) return false + if (Date.now() - lastOverride > POST_DUPLICATE_LOOKBACK_MS) { + duplicateOverrideRef.current.delete(signature) + return false + } + return true + } + + const setDuplicateOverride = (signature: string): void => { + duplicateOverrideRef.current.set(signature, Date.now()) + } + + const buildDuplicateSignature = (data: { + type: 'post' | 'reply' + content: string + quotedPostId?: string + quotedPostOwnerId?: string + parentId?: string | null + parentOwnerId?: string | null + }): string => { + const quoteId = data.quotedPostId ?? '' + const quoteOwnerId = data.quotedPostOwnerId ?? '' + const parentId = data.parentId ?? '' + const parentOwnerId = data.parentOwnerId ?? '' + return `${data.type}:${data.content}::${quoteId}:${quoteOwnerId}::${parentId}:${parentOwnerId}` + } + + const checkRecentDuplicate = async (params: { + ownerId: string + type: 'post' | 'reply' + content: string + quotedPostId?: string + quotedPostOwnerId?: string + parentId?: string | null + parentOwnerId?: string | null + }): Promise => { + const minCreatedAt = Date.now() - POST_DUPLICATE_LOOKBACK_MS + try { + if (params.type === 'reply') { + const { replyService } = await import('@/lib/services/reply-service') + const result = await replyService.getUserReplies(params.ownerId, { + where: [ + ['$ownerId', '==', params.ownerId], + ['$createdAt', '>', minCreatedAt] + ], + orderBy: [['$ownerId', 'asc'], ['$createdAt', 'desc']], + limit: 20, + skipEnrichment: true + }) + + return result.documents.some((reply) => + !reply.encryptedContent && + reply.content === params.content && + reply.parentId === params.parentId && + reply.parentOwnerId === params.parentOwnerId + ) + } + + const { postService } = await import('@/lib/services') + const result = await postService.getUserPosts(params.ownerId, { + where: [ + ['$ownerId', '==', params.ownerId], + ['$createdAt', '>', minCreatedAt] + ], + orderBy: [['$ownerId', 'asc'], ['$createdAt', 'desc']], + limit: 20 + }) + + const expectedQuotedId = params.quotedPostId ?? null + const expectedQuotedOwnerId = params.quotedPostOwnerId ?? null + + return result.documents.some((post) => + !post.encryptedContent && + post.content === params.content && + (post.quotedPostId ?? null) === expectedQuotedId && + (post.quotedPostOwnerId ?? null) === expectedQuotedOwnerId + ) + } catch (error) { + console.warn('Duplicate pre-check failed; continuing without block:', error) + return false + } + } + const handlePost = async () => { const authedUser = requireAuth('post') if (!authedUser || !canPost) return @@ -479,6 +566,9 @@ export function ComposeModal() { const timeoutPosts: { index: number; threadPostId: string }[] = [] // Posts that timed out (may have succeeded) let failedAtIndex: number | null = null let failureError: Error | null = null + let duplicateDetectedAtIndex: number | null = null + let duplicateDetectedThreadPostId: string | null = null + let duplicateDetectedIsReply = false // Upload image first if attached (and not already uploaded) let imageUrl: string | undefined @@ -576,6 +666,39 @@ export function ComposeModal() { ? replyingTo.author.id : previousPostId ? authedUser.identityId : undefined + const shouldCheckDuplicate = !isThisPostPrivate && !isThisReplyInherited + const duplicateSignature = buildDuplicateSignature({ + type: isReply ? 'reply' : 'post', + content: postContent, + quotedPostId: i === 0 ? quotingPost?.id : undefined, + quotedPostOwnerId: i === 0 ? quotingPost?.author.id : undefined, + parentId, + parentOwnerId, + }) + const hasOverride = isDuplicateOverrideActive(duplicateSignature) + + if (shouldCheckDuplicate && !hasOverride) { + const isDuplicate = await checkRecentDuplicate({ + ownerId: authedUser.identityId, + type: isReply ? 'reply' : 'post', + content: postContent, + quotedPostId: i === 0 ? quotingPost?.id : undefined, + quotedPostOwnerId: i === 0 ? quotingPost?.author.id : undefined, + parentId, + parentOwnerId, + }) + + if (isDuplicate) { + setDuplicateOverride(duplicateSignature) + duplicateDetectedAtIndex = i + duplicateDetectedThreadPostId = threadPostId + duplicateDetectedIsReply = isReply + break + } + } else if (hasOverride) { + duplicateOverrideRef.current.delete(duplicateSignature) + } + const result = await retryPostCreation(async () => { // Check for sync required errors before they get wrapped by retry try { @@ -753,6 +876,26 @@ export function ComposeModal() { } // Handle results based on success/failure/timeout state + if (duplicateDetectedAtIndex !== null) { + // Mark confirmed successful posts as posted + successfulPosts.forEach(({ threadPostId, postId }) => { + markThreadPostAsPosted(threadPostId, postId) + }) + + const duplicateLabel = duplicateDetectedIsReply ? 'reply' : 'post' + toast( + `A very recent ${duplicateLabel} with the same content was found. ` + + `Press Post again to send anyway.`, + { duration: 6000, icon: '⚠️' } + ) + + if (duplicateDetectedThreadPostId) { + setActiveThreadPost(duplicateDetectedThreadPostId) + } + + return + } + const allSuccessful = failedAtIndex === null && timeoutPosts.length === 0 const hasTimeouts = timeoutPosts.length > 0 const successfulThreadPostIds = new Set(successfulPosts.map(p => p.threadPostId)) diff --git a/lib/constants.ts b/lib/constants.ts index 75dafbef..653ee920 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -32,6 +32,9 @@ export const POST_RECOVERY_LOOKBACK_MS = 2 * 60 * 1000 export const POST_RECOVERY_POLL_ATTEMPTS = 3 export const POST_RECOVERY_POLL_DELAY_MS = 2000 +// Pre-flight duplicate detection +export const POST_DUPLICATE_LOOKBACK_MS = 2 * 60 * 1000 + // Document types // Note: AVATAR, REPOST, DIRECT_MESSAGE, NOTIFICATION were removed in contract migration // - avatar: now in unified profile contract From 650a5fc9f4dff793e0c6d6e5ce802ae292eb3be7 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 4 Feb 2026 12:15:15 -0600 Subject: [PATCH 4/9] Fix duplicate check reply flag --- components/compose/compose-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/compose/compose-modal.tsx b/components/compose/compose-modal.tsx index 161fc941..4a4f5b03 100644 --- a/components/compose/compose-modal.tsx +++ b/components/compose/compose-modal.tsx @@ -660,7 +660,7 @@ export function ComposeModal() { // Determine if this is a reply (to existing post/reply) or a top-level post // - If replyingTo is set: all posts in thread are replies // - If replyingTo is not set: first post is a top-level post, subsequent are replies - const isReply = (i === 0 && replyingTo) || (i > 0 && previousPostId) + const isReply = Boolean((i === 0 && replyingTo) || (i > 0 && previousPostId)) const parentId = i === 0 && replyingTo ? replyingTo.id : previousPostId const parentOwnerId = i === 0 && replyingTo ? replyingTo.author.id From 383f271fd0f97f84eb39f1b409abe55134e286d0 Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 4 Feb 2026 22:45:01 -0600 Subject: [PATCH 5/9] Broaden ambiguous post error detection --- lib/retry-utils.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/lib/retry-utils.ts b/lib/retry-utils.ts index a3deebce..f7edc461 100644 --- a/lib/retry-utils.ts +++ b/lib/retry-utils.ts @@ -23,10 +23,8 @@ export interface RetryResult { function defaultRetryCondition(error: unknown): boolean { if (!error) return false - const errObj = error as { message?: string; toString?: () => string } - const errorMessage = errObj.message?.toLowerCase() || '' - const errorString = errObj.toString?.()?.toLowerCase() || '' - + const errorMessage = getErrorMessage(error).toLowerCase() + // Network-related errors const networkErrors = [ 'network error', @@ -34,6 +32,8 @@ function defaultRetryCondition(error: unknown): boolean { 'fetch failed', 'connection refused', 'timeout', + 'timed out', + 'deadline', 'etimedout', 'enotfound', 'econnreset', @@ -45,7 +45,7 @@ function defaultRetryCondition(error: unknown): boolean { // Check if it's a retryable error return networkErrors.some(networkError => - errorMessage.includes(networkError) || errorString.includes(networkError) + errorMessage.includes(networkError) ) } @@ -54,8 +54,21 @@ function getErrorMessage(error: unknown): string { if (error instanceof Error) return error.message if (typeof error === 'string') return error if (typeof error === 'object') { - const errObj = error as { message?: string } + const errObj = error as { message?: unknown } if (typeof errObj.message === 'string') return errObj.message + if (errObj.message && typeof errObj.message === 'object') { + try { + return JSON.stringify(errObj.message) + } catch { + return String(errObj.message) + } + } + try { + const asJson = JSON.stringify(error) + if (asJson && asJson !== '{}') return asJson + } catch { + // Ignore JSON stringify errors + } } return String(error) } From 24a6cf21de59d5ea81a6dd590ca9d222c6787d9b Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 25 Aug 2026 12:31:23 +0200 Subject: [PATCH 6/9] feat: recover posts and replies by exact document ID after ambiguous errors Generate the document ID and entropy before broadcasting (IDs are deterministic over ownerId + contractId + documentType + entropy), so an ambiguous broadcast failure (timeout, gateway 5xx, tenderdash unavailable) can be resolved by polling Platform for that exact ID instead of matching recent documents by content. state-transition-service now reports the pre-computed documentId and whether a broadcast was attempted on failed creates. createWithOptions surfaces these via DocumentCreateError so callers can distinguish definite pre-broadcast failures (safe to retry) from ambiguous post-broadcast ones. When recovery polling cannot confirm the document, createWithAmbiguityRecovery throws PostCreationIndeterminateError, which retryPostCreation treats as non-retryable - rebroadcasting would mint a new document with fresh entropy (and a fresh nonce for encrypted posts) and could duplicate the original if it later commits. Co-Authored-By: Claude Fable 5 --- lib/services/document-service.ts | 94 +++++++++++++++++++++++- lib/services/post-service.ts | 4 +- lib/services/reply-service.ts | 4 +- lib/services/state-transition-service.ts | 23 +++++- 4 files changed, 119 insertions(+), 6 deletions(-) diff --git a/lib/services/document-service.ts b/lib/services/document-service.ts index 2413e4d0..133f0b06 100644 --- a/lib/services/document-service.ts +++ b/lib/services/document-service.ts @@ -1,9 +1,32 @@ import { logger } from '@/lib/logger'; import { getEvoSdk } from './evo-sdk-service'; import { stateTransitionService } from './state-transition-service'; -import { YAPPR_CONTRACT_ID } from '../constants'; +import { documentBuilderService } from './document-builder-service'; +import { YAPPR_CONTRACT_ID, POST_RECOVERY_POLL_ATTEMPTS, POST_RECOVERY_POLL_DELAY_MS } from '../constants'; +import { isPostCreationAmbiguousError, PostCreationIndeterminateError } from '../retry-utils'; import { documentToPlainObject, queryDocuments, type QueryDocumentsOptions, type DocumentWhereClause, type DocumentOrderByClause } from './sdk-helpers'; +/** + * Error thrown when a document create fails, preserving whether the + * broadcast stage was reached so callers can classify the failure: + * - `broadcastAttempted === false`: definite failure, safe to retry. + * - `broadcastAttempted === true`: ambiguous — the state transition may + * still commit on Platform; retrying with fresh entropy risks duplicates. + */ +export class DocumentCreateError extends Error { + readonly documentId?: string; + readonly broadcastAttempted: boolean; + + constructor(message: string, options: { documentId?: string; broadcastAttempted?: boolean } = {}) { + super(message); + this.name = 'DocumentCreateError'; + this.documentId = options.documentId; + this.broadcastAttempted = options.broadcastAttempted ?? false; + // Restore prototype chain for environments that transpile class extends + Object.setPrototypeOf(this, DocumentCreateError.prototype); + } +} + export interface QueryOptions { where?: DocumentWhereClause[]; orderBy?: DocumentOrderByClause[]; @@ -194,7 +217,10 @@ export abstract class BaseDocumentService { ); if (!result.success || !result.document) { - throw new Error(result.error || 'Failed to create document'); + throw new DocumentCreateError(result.error || 'Failed to create document', { + documentId: result.documentId ?? options?.documentId, + broadcastAttempted: result.broadcastAttempted, + }); } // Clear relevant caches @@ -214,6 +240,70 @@ export abstract class BaseDocumentService { } } + /** + * Create a document with recovery from ambiguous broadcast failures. + * + * The document ID is generated deterministically BEFORE broadcasting + * (from ownerId + contractId + documentType + entropy). If the create + * fails after the broadcast stage with an ambiguous error (timeout, + * gateway 5xx, "tenderdash not available", ...), the transition may still + * have committed — so we poll Platform for that exact document ID instead + * of rebroadcasting a new document. + * + * Outcomes: + * - Success: the created (or recovered) document. + * - Definite failure (pre-broadcast, or a hard rejection): original error + * is rethrown; retrying is safe. + * - Ambiguous failure with no recovery: PostCreationIndeterminateError is + * thrown. It is non-retryable — rebroadcasting would create a NEW + * document with fresh entropy (and a fresh nonce for encrypted content), + * risking duplicates if the original transition later commits. + */ + protected async createWithAmbiguityRecovery(ownerId: string, data: Record): Promise { + const { id: documentId, entropy } = await documentBuilderService.generateDocumentIdentity( + this.contractId, + this.documentType, + ownerId + ); + + try { + return await this.createWithOptions(ownerId, data, { documentId, entropy }); + } catch (error) { + const broadcastAttempted = error instanceof DocumentCreateError && error.broadcastAttempted; + if (broadcastAttempted && isPostCreationAmbiguousError(error)) { + logger.warn(`${this.documentType} create failed ambiguously — polling for document ${documentId}`); + const recovered = await this.pollForCreatedDocument(documentId); + if (recovered) { + logger.info(`Recovered ${this.documentType} ${documentId} after ambiguous create error`); + this.clearCache(); + return recovered; + } + logger.warn(`Could not confirm ${this.documentType} ${documentId} — surfacing indeterminate outcome`); + throw new PostCreationIndeterminateError(this.documentType, documentId, error); + } + throw error; + } + } + + /** + * Poll Platform for a document by its exact ID after an ambiguous + * create failure. Returns null if it never becomes visible. + */ + private async pollForCreatedDocument(documentId: string): Promise { + for (let attempt = 1; attempt <= POST_RECOVERY_POLL_ATTEMPTS; attempt++) { + if (attempt > 1) { + await new Promise(resolve => setTimeout(resolve, POST_RECOVERY_POLL_DELAY_MS)); + } + // get() returns null on lookup errors, so a flaky network during + // recovery degrades to the indeterminate outcome rather than throwing. + const document = await this.get(documentId); + if (document) { + return document; + } + } + return null; + } + /** * Extract content fields from a transformed document, stripping system metadata. * Used to build the full document data for replacements (updates). diff --git a/lib/services/post-service.ts b/lib/services/post-service.ts index 4acc3c87..e5f837b2 100644 --- a/lib/services/post-service.ts +++ b/lib/services/post-service.ts @@ -265,7 +265,9 @@ class PostService extends BaseDocumentService { if (options.primaryHashtag) data.primaryHashtag = options.primaryHashtag; if (options.sensitive !== undefined) data.sensitive = options.sensitive; - return this.create(ownerId, data); + // Create with a pre-generated document ID so ambiguous broadcast failures + // can be recovered by exact-ID lookup instead of rebroadcasting. + return this.createWithAmbiguityRecovery(ownerId, data); } /** diff --git a/lib/services/reply-service.ts b/lib/services/reply-service.ts index 7fa78ad8..08465256 100644 --- a/lib/services/reply-service.ts +++ b/lib/services/reply-service.ts @@ -187,7 +187,9 @@ class ReplyService extends BaseDocumentService { if (options.mediaUrl) data.mediaUrl = options.mediaUrl; if (options.sensitive !== undefined) data.sensitive = options.sensitive; - return this.create(ownerId, data); + // Create with a pre-generated document ID so ambiguous broadcast failures + // can be recovered by exact-ID lookup instead of rebroadcasting. + return this.createWithAmbiguityRecovery(ownerId, data); } /** diff --git a/lib/services/state-transition-service.ts b/lib/services/state-transition-service.ts index 2629ffe8..7e2fb0fb 100644 --- a/lib/services/state-transition-service.ts +++ b/lib/services/state-transition-service.ts @@ -24,6 +24,14 @@ export interface StateTransitionResult { /** Whether the document is confirmed query-visible on Platform. */ confirmed?: boolean; error?: string; + /** The pre-computed document ID for create operations (set even on failure once known). */ + documentId?: string; + /** + * Whether a broadcast was attempted for this document before the failure. + * When true, a failed create is AMBIGUOUS: the state transition may still + * commit on Platform, so callers must not blindly rebroadcast a new document. + */ + broadcastAttempted?: boolean; } /** Key for localStorage ST cache */ @@ -298,6 +306,11 @@ class StateTransitionService { entropy?: Uint8Array; } ): Promise { + // Track how far the create got so failures can be classified: + // a failure BEFORE any broadcast is definite (safe to retry), while a + // failure at/after broadcast is ambiguous (the ST may still commit). + let documentId: string | undefined; + let broadcastAttempted = false; try { const sdk = await getEvoSdk(); const wasm = sdk.wasm; @@ -330,12 +343,15 @@ class StateTransitionService { entropy: options?.entropy, } ); - const documentId = documentBuilderService.getDocumentId(document); + documentId = documentBuilderService.getDocumentId(document); logger.info(`Built document, ID: ${documentId}`); // --- Check for a cached ST from a previous timed-out attempt --- const cachedBytes = loadPendingSTBytes(documentId); if (cachedBytes) { + // A cached ST means a previous attempt already reached the broadcast + // stage for this exact document ID — treat failures as ambiguous. + broadcastAttempted = true; logger.info(`Found cached ST bytes for ${documentId} — checking Platform...`); // First check if it already landed @@ -443,6 +459,7 @@ class StateTransitionService { // Broadcast via StateTransitionsFacade (v3.1) try { + broadcastAttempted = true; await sdk.stateTransitions.broadcastStateTransition(stateTransition); logger.info('Broadcast succeeded, waiting for confirmation...'); } catch (broadcastErr) { @@ -543,7 +560,9 @@ class StateTransitionService { logger.error('Error creating document:', error); return { success: false, - error: extractErrorMessage(error) + error: extractErrorMessage(error), + documentId, + broadcastAttempted }; } } From e6ddbdeb503621aee7e7eeb1b30278f3a804154f Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 25 Aug 2026 12:31:28 +0200 Subject: [PATCH 7/9] feat: surface indeterminate post outcomes in compose instead of retrying When post/reply creation ends indeterminate (broadcast may have committed but exact-ID recovery could not confirm it), stop the thread, keep the post editable, and tell the user to check their profile before pressing Post again - never auto-rebroadcast. Also route the duplicate pre-check logging through the shared logger. Co-Authored-By: Claude Fable 5 --- components/compose/compose-modal.tsx | 36 ++++++++++++++++++---------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/components/compose/compose-modal.tsx b/components/compose/compose-modal.tsx index c256acb9..6948b953 100644 --- a/components/compose/compose-modal.tsx +++ b/components/compose/compose-modal.tsx @@ -566,7 +566,7 @@ export function ComposeModal() { (post.quotedPostOwnerId ?? null) === expectedQuotedOwnerId ) } catch (error) { - console.warn('Duplicate pre-check failed; continuing without block:', error) + logger.warn('Duplicate pre-check failed; continuing without block:', error) return false } } @@ -613,7 +613,7 @@ export function ComposeModal() { } try { - const { retryPostCreation } = await import('@/lib/retry-utils') + const { retryPostCreation, isPostCreationIndeterminateError } = await import('@/lib/retry-utils') // Check if this is a private post (explicit or inherited) const isPrivate = visibility === 'private' || visibility === 'private-with-teaser' @@ -901,11 +901,22 @@ export function ComposeModal() { break } } else { - // Check if this is a timeout error - the state-transition-service already - // tried to verify on Platform. If we still get here, it couldn't confirm. - // Retrying is safe — idempotency checks will prevent double-posting. + // Ambiguous outcome: the broadcast may have committed, and the + // service already polled Platform for the exact document ID without + // finding it. Do NOT retry — a new attempt would broadcast a fresh + // document (new entropy/ID) and could duplicate this one. Stop the + // thread here and let the user check their profile first. + if (isPostCreationIndeterminateError(result.error)) { + logger.warn(`Post ${i + 1} outcome indeterminate (document ${result.error.documentId}) — it may have been created.`) + timeoutPosts.push({ index: i, threadPostId }) + break + } + + // Remaining timeout errors are pre-broadcast failures (post-broadcast + // ambiguity surfaces as PostCreationIndeterminateError above), so the + // document was not created and pressing Post again is safe. if (isTimeoutError(result.error)) { - logger.warn(`Post ${i + 1} timed out and could not be verified — may have succeeded.`) + logger.warn(`Post ${i + 1} timed out before it could be broadcast.`) timeoutPosts.push({ index: i, threadPostId }) continue } @@ -979,19 +990,20 @@ export function ComposeModal() { if (confirmedCount > 0 && timeoutCount > 0) { toast( `${confirmedCount} post${confirmedCount > 1 ? 's' : ''} confirmed. ` + - `${timeoutCount} post${timeoutCount > 1 ? 's' : ''} timed out - press Post to retry.`, - { duration: 5000, icon: '⚠️' } + `${timeoutCount} post${timeoutCount > 1 ? 's' : ''} could not be confirmed and may have been created — ` + + `check your profile before pressing Post again.`, + { duration: 6000, icon: '⚠️' } ) - // Keep modal open for retry - set active to first timed-out post + // Keep modal open for retry - set active to first unconfirmed post const firstTimeout = timeoutPosts[0] if (firstTimeout) { setActiveThreadPost(firstTimeout.threadPostId) } } else if (timeoutCount > 0) { toast( - `${timeoutCount} post${timeoutCount > 1 ? 's' : ''} timed out. ` + - `Press Post to retry, or check your profile.`, - { duration: 5000, icon: '⚠️' } + `Your post${timeoutCount > 1 ? 's' : ''} could not be confirmed and may have been created — ` + + `check your profile before pressing Post again.`, + { duration: 6000, icon: '⚠️' } ) // Keep modal open for retry } else { From 55c32087acfa8fd1b96786dfb55dd4960d3370a6 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 25 Aug 2026 13:53:56 +0200 Subject: [PATCH 8/9] fix: default to indeterminate after broadcast and widen recovery window After a broadcast attempt, an unrecognized error message must not be classified as a definite failure - that would invite the user to press Post again and mint a duplicate document. Gate on a narrow isDefiniteRejectionError list (validation/consensus rejections that prove the transition was rejected) and treat everything else as ambiguous. Widen the exact-ID recovery poll to 5x3s (about 12s), and 8 attempts (about 21s) for encrypted documents: ciphertext is not queryable, so the compose duplicate pre-check cannot protect encrypted posts and a manual retry re-encrypts with a fresh nonce, making exact-ID recovery their only duplicate safety net. Co-Authored-By: Claude Fable 5 --- lib/constants.ts | 10 ++++++++-- lib/retry-utils.ts | 34 ++++++++++++++++++++++++++++++++ lib/services/document-service.ts | 25 +++++++++++++++++------ 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/lib/constants.ts b/lib/constants.ts index ce7777d9..729a74c1 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -39,8 +39,14 @@ export const INSIGHT_API_CONFIG = { // Post creation recovery (ambiguous errors): after an ambiguous broadcast // failure we poll for the exact document ID that was generated pre-broadcast. -export const POST_RECOVERY_POLL_ATTEMPTS = 3 -export const POST_RECOVERY_POLL_DELAY_MS = 2000 +// The window is deliberately generous — the errors that trigger recovery +// (gateway 5xx, "tenderdash not available", timeouts) are exactly the +// conditions under which Platform is slow to make documents query-visible. +export const POST_RECOVERY_POLL_ATTEMPTS = 5 +export const POST_RECOVERY_POLL_DELAY_MS = 3000 +// Encrypted documents get a longer window: their ciphertext is not queryable, +// so exact-ID recovery is the only duplicate protection they have. +export const POST_RECOVERY_POLL_ATTEMPTS_ENCRYPTED = 8 // Pre-flight duplicate detection in the compose modal export const POST_DUPLICATE_LOOKBACK_MS = 2 * 60 * 1000 diff --git a/lib/retry-utils.ts b/lib/retry-utils.ts index 5677d2f7..aa06837f 100644 --- a/lib/retry-utils.ts +++ b/lib/retry-utils.ts @@ -128,6 +128,40 @@ export function isPostCreationAmbiguousError(error: unknown): boolean { return dashErrors.some(dashError => errorText.includes(dashError)) } +/** + * Check whether an error PROVES the state transition was rejected by + * Platform (validation/consensus rejection), meaning the document was + * definitely NOT created. + * + * This list is intentionally narrow. It gates the ambiguity handling after a + * broadcast was attempted: anything NOT in this list is treated as ambiguous + * (the transition may still commit), because wrongly calling an outcome + * "definite failure" invites the user to rebroadcast and create a duplicate, + * while wrongly calling it "ambiguous" only costs a "check your profile" + * prompt. + */ +export function isDefiniteRejectionError(error: unknown): boolean { + const errorText = getErrorText(error) + + const rejectionErrors = [ + 'state transition is invalid', + 'invalid state transition', + 'validation error', + 'validation failed', + 'schema validation', + 'invalid document', + 'document type not found', + 'missing required property', + 'invalid signature', + 'signature verification', + 'insufficient balance', + 'balance is not enough', + 'not enough balance' + ] + + return rejectionErrors.some(rejection => errorText.includes(rejection)) +} + /** * Exponential backoff with jitter */ diff --git a/lib/services/document-service.ts b/lib/services/document-service.ts index 133f0b06..46392618 100644 --- a/lib/services/document-service.ts +++ b/lib/services/document-service.ts @@ -2,8 +2,8 @@ import { logger } from '@/lib/logger'; import { getEvoSdk } from './evo-sdk-service'; import { stateTransitionService } from './state-transition-service'; import { documentBuilderService } from './document-builder-service'; -import { YAPPR_CONTRACT_ID, POST_RECOVERY_POLL_ATTEMPTS, POST_RECOVERY_POLL_DELAY_MS } from '../constants'; -import { isPostCreationAmbiguousError, PostCreationIndeterminateError } from '../retry-utils'; +import { YAPPR_CONTRACT_ID, POST_RECOVERY_POLL_ATTEMPTS, POST_RECOVERY_POLL_ATTEMPTS_ENCRYPTED, POST_RECOVERY_POLL_DELAY_MS } from '../constants'; +import { isDefiniteRejectionError, PostCreationIndeterminateError } from '../retry-utils'; import { documentToPlainObject, queryDocuments, type QueryDocumentsOptions, type DocumentWhereClause, type DocumentOrderByClause } from './sdk-helpers'; /** @@ -270,9 +270,22 @@ export abstract class BaseDocumentService { return await this.createWithOptions(ownerId, data, { documentId, entropy }); } catch (error) { const broadcastAttempted = error instanceof DocumentCreateError && error.broadcastAttempted; - if (broadcastAttempted && isPostCreationAmbiguousError(error)) { + // Once a broadcast has been attempted, DEFAULT to treating the failure + // as ambiguous: only errors that prove Platform rejected the transition + // (validation/consensus rejections) are definite. An unrecognized error + // message must not be allowed to invite a retry — a rebroadcast with + // fresh entropy would duplicate the document if the original commits. + if (broadcastAttempted && !isDefiniteRejectionError(error)) { logger.warn(`${this.documentType} create failed ambiguously — polling for document ${documentId}`); - const recovered = await this.pollForCreatedDocument(documentId); + // Encrypted documents get a longer recovery window: their ciphertext + // is not queryable, so the compose duplicate pre-check cannot protect + // against a manual re-post. If the user retries anyway, the content is + // re-encrypted with a fresh nonce and the duplicate cannot be detected + // at all — finding the original here is the only safety net. + const attempts = data.encryptedContent + ? POST_RECOVERY_POLL_ATTEMPTS_ENCRYPTED + : POST_RECOVERY_POLL_ATTEMPTS; + const recovered = await this.pollForCreatedDocument(documentId, attempts); if (recovered) { logger.info(`Recovered ${this.documentType} ${documentId} after ambiguous create error`); this.clearCache(); @@ -289,8 +302,8 @@ export abstract class BaseDocumentService { * Poll Platform for a document by its exact ID after an ambiguous * create failure. Returns null if it never becomes visible. */ - private async pollForCreatedDocument(documentId: string): Promise { - for (let attempt = 1; attempt <= POST_RECOVERY_POLL_ATTEMPTS; attempt++) { + private async pollForCreatedDocument(documentId: string, maxAttempts: number): Promise { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { if (attempt > 1) { await new Promise(resolve => setTimeout(resolve, POST_RECOVERY_POLL_DELAY_MS)); } From 95dfd92b0958c0efa2c8fea2cc63636d2bf5dd2a Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Tue, 25 Aug 2026 08:01:00 -0500 Subject: [PATCH 9/9] fix: preserve reply mode when resuming thread --- components/compose/compose-modal.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/compose/compose-modal.tsx b/components/compose/compose-modal.tsx index 6948b953..6ababc3e 100644 --- a/components/compose/compose-modal.tsx +++ b/components/compose/compose-modal.tsx @@ -692,7 +692,8 @@ export function ComposeModal() { // Determine if this is a reply (to existing post/reply) or a top-level post // - If replyingTo is set: all posts in thread are replies // - If replyingTo is not set: first post is a top-level post, subsequent are replies - const isReply = Boolean((i === 0 && replyingTo) || (i > 0 && previousPostId)) + // - If resuming an interrupted thread: the first pending post replies to lastPostedId + const isReply = Boolean(replyingTo || previousPostId) const parentId = i === 0 && replyingTo ? replyingTo.id : previousPostId const parentOwnerId = i === 0 && replyingTo ? replyingTo.author.id