Something went wrong. Try again.
Atproto AMA app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291import { 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<typeof schema>;
/** * 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<void> { 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<void> { 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<boolean> { 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<UnanswerOutcome> { 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 };}