import { and, eq, inArray } from "drizzle-orm"; import type { LibSQLDatabase } from "drizzle-orm/libsql"; import { answers, answerContents, questions } from "~/lib/schema"; import type * as schema from "~/lib/schema"; import { deleteRecord, extractRkeyFromUri } from "~/lib/at-protocol"; import { getCachedConfig } from "~/lib/config"; import { isExternalAtUri } from "~/lib/answer-content/registry"; type DB = LibSQLDatabase; /** * Shared un-answer orchestration (ADR-0021). The server action owns session, * ownership, and revalidation; this core owns the PDS-write order, the scope * algebra (link vs purge), the projection cascade, and DB convergence. * * PDS deletions always precede DB writes (unblockUser pattern). Partial core * deletions reconcile through Jetstream delete echoes (ADR-0005, at-least-once * idempotent ingestion); the refreshable DB state below stays authoritative * for Hidden/dismiss markers, which ingestion never writes. * * Projection (Cross-post / Broadcast) deletion failures never block the * un-answer: they surface as `projectionCleanupFailed` for the existing * retry affordances (ADR-0021 consequence). */ export type UnanswerScope = "link" | "purge"; export type UnanswerDecision = { /** "link": delete this Answer (link) only. "purge": delete the Answer Content and every Answer linked to it. */ scope: UnanswerScope; /** When true (link scope), also dismiss the Question (delete both: un-answer + dismiss). */ dismissAffected: boolean; /** When true, also delete the surviving Derived Answer's own bsky reply (the "delete both" modal, ADR-0021 amendment). */ deleteDerivedBskyReply: boolean; }; export type UnanswerOutcome = { /** Question ids that lost their Answer and were dismissed (purge) or are the current one (link + dismiss). */ dismissedQuestionIds: string[]; /** True when any Cross-post/Broadcast deletion failed but the core un-answer succeeded. */ projectionCleanupFailed: boolean; }; /** * Orphaned answer cleanup: the question record is already gone (deleted by * the Asker, native or bsky source alike). PDS-deletes the dangling answer * record (or a Derived Answer's own bsky reply) plus its projections, then * soft-deletes the row. */ export async function cleanupOrphanedAnswerCore( db: DB, params: { answerId: string }, ): Promise { const answer = await db.query.answers.findFirst({ where: and(eq(answers.id, params.answerId), eq(answers.deleted, false)), }); if (!answer) return; const recipientDid = answer.authorDid; if (getCachedConfig().atProtocol.enabled) { const failures: string[] = []; await deleteProjection(recipientDid, answer.crossPostUri, failures); await deleteProjection(recipientDid, answer.broadcastUri, failures); // Native explicit answer: the link record itself. Derived answer: the // Recipient's own bsky reply (its at-uri travels in answerContentUri). const recordUri = answer.derived === true && !answer.atUri ? answer.answerContentUri : answer.atUri; const recordRkey = recordUri ? extractRkeyFromUri(recordUri) : null; if (recordRkey) { try { await deleteRecord( recipientDid, answer.derived === true && !answer.atUri ? BSKY_POST_COLLECTION : ANSWER_COLLECTION, recordRkey, ); } catch (error) { console.error("[answer-delete] orphan record deletion failed:", recordUri, error); failures.push(recordUri!); } } void failures; } await db .update(answers) .set({ deleted: true }) .where(and(eq(answers.id, params.answerId), eq(answers.deleted, false))); } const BSKY_POST_COLLECTION = "app.bsky.feed.post"; const ANSWER_COLLECTION = "at.askimut.answer"; const ANSWER_CONTENT_COLLECTION = "at.askimut.answerContent"; async function deleteProjection( recipientDid: string, atUri: string | null, failures: string[], ): Promise { if (!atUri) return; const rkey = extractRkeyFromUri(atUri); if (!rkey) return; try { await deleteRecord(recipientDid, BSKY_POST_COLLECTION, rkey); } catch (error) { console.error("[answer-delete] projection deletion failed:", atUri, error); failures.push(atUri); } } /** * Deletes every projection record (Cross-post, Broadcast) attached to the * given Answer rows. Also deletes a Derived Answer's own bsky reply when * driven by the delete-both modal. */ async function cleanupProjections( db: DB, recipientDid: string, rows: (typeof answers.$inferSelect)[], deleteDerivedBskyReply: boolean, ): Promise { if (!getCachedConfig().atProtocol.enabled) return false; const failures: string[] = []; for (const row of rows) { await deleteProjection(recipientDid, row.crossPostUri, failures); await deleteProjection(recipientDid, row.broadcastUri, failures); } if (deleteDerivedBskyReply) { const derivedRows = await db .select({ answerContentUri: answers.answerContentUri }) .from(answers) .where( and( eq(answers.derived, true), eq(answers.deleted, false), inArray(answers.questionId, rows.map((row) => row.questionId!)), ), ); for (const derived of derivedRows) { // Derived Answers carry the bsky reply at-uri in answerContentUri; the // Reply is the Recipient's own post — only deleted via user-triggered // action per the amended ADR-0008. const rkey = extractRkeyFromUri(derived.answerContentUri); if (!rkey) continue; try { await deleteRecord(recipientDid, BSKY_POST_COLLECTION, rkey); } catch (error) { console.error("[answer-delete] derived reply deletion failed:", derived.answerContentUri, error); failures.push(derived.answerContentUri); } } } return failures.length > 0; } /** * Un-answer: PDS-deletes the Answer (link) record, optionally purges the * shared Answer Content and all sibling Answers, cascades projection * deletion, and converges the DB. Pass `deleteDerivedBskyReply` only from * the derived-answer "delete both" modal. */ export async function unanswerAnswerCore( db: DB, params: { questionId: string; decision: UnanswerDecision }, ): Promise { const decision = params.decision; const answer = await db.query.answers.findFirst({ where: and( eq(answers.questionId, params.questionId), eq(answers.deleted, false), eq(answers.derived, false), ), }); if (!answer) { return { dismissedQuestionIds: [], projectionCleanupFailed: false }; } const recipientDid = answer.authorDid; // Scope expansion: link = just this row; purge = this content's every live link. const targetRows = decision.scope === "purge" ? await db .select() .from(answers) .where( and( eq(answers.answerContentUri, answer.answerContentUri), eq(answers.deleted, false), ), ) : [answer]; const projectionCleanupFailed = await cleanupProjections( db, recipientDid, targetRows, decision.deleteDerivedBskyReply, ); if (getCachedConfig().atProtocol.enabled) { for (const row of targetRows) { const rkey = row.atUri ? extractRkeyFromUri(row.atUri) : null; if (!rkey) continue; await deleteRecord(recipientDid, ANSWER_COLLECTION, rkey); } if (decision.scope === "purge") { const content = await db.query.answerContents.findFirst({ where: eq(answerContents.id, answer.answerContentId!), }); // Only native Answer Content has a PDS record to delete; external // content rows are just caches (ADR-0002). if (content && content.atUri && !isExternalAtUri(content.atUri)) { const contentRkey = extractRkeyFromUri(content.atUri); if (contentRkey) { await deleteRecord(recipientDid, ANSWER_CONTENT_COLLECTION, contentRkey); } } } } // DB convergence: soft-delete the rows (the Jetstream echoes rewrite the // same state idempotently later). // DB convergence: soft-delete the rows (the Jetstream echoes rewrite the // same state idempotently later). await db .update(answers) .set({ deleted: true }) .where( and( inArray(answers.id, targetRows.map((row) => row.id)), eq(answers.deleted, false), ), ); if (decision.deleteDerivedBskyReply) { await db .update(answers) .set({ deleted: true }) .where( and( eq(answers.derived, true), eq(answers.deleted, false), inArray( answers.questionId, targetRows.map((row) => row.questionId!), ), ), ); } const dismissedQuestionIds: string[] = []; if (decision.scope === "purge") { const contentRow = await db.query.answerContents.findFirst({ where: eq(answerContents.id, answer.answerContentId!), }); if (contentRow) { await db .update(answerContents) .set({ deleted: true }) .where(and(eq(answerContents.id, contentRow.id), eq(answerContents.deleted, false))); } } if (decision.scope === "link" ? decision.dismissAffected : true) { for (const row of targetRows) { if (row.questionId) dismissedQuestionIds.push(row.questionId); } if (dismissedQuestionIds.length > 0) { await db .update(questions) .set({ dismissed: true, hidden: false }) .where(and(inArray(questions.id, dismissedQuestionIds), eq(questions.dismissed, false))); } } // Un-answering clears a stale Hide: the Question returns to Pending as a // fresh, answerable item. await db .update(questions) .set({ hidden: false }) .where(eq(questions.id, params.questionId)); return { dismissedQuestionIds, projectionCleanupFailed }; }