1import type { EnvironmentId } from "@t3tools/contracts";
2
3import { appAtomRegistry } from "./atom-registry";
4import { threadOutboxManager } from "./thread-outbox";
5import type { QueuedThreadMessage } from "./thread-outbox-model";
6import {
7 clearComposerDraft,
8 composerDraftsAtom,
9 flushComposerDrafts,
10 scheduleUnusedComposerAttachmentCleanup,
11 waitForComposerDraftsLoaded,
12} from "./use-composer-drafts";
13
14async function cleanUpRemovedMessages(
15 removedMessages: ReadonlyArray<QueuedThreadMessage>,
16): Promise<void> {
17 const attachments = removedMessages.flatMap((message) => message.attachments);
18 const removedCreations = removedMessages.filter((message) => message.creation !== undefined);
19 if (removedCreations.length === 0) {
20 scheduleUnusedComposerAttachmentCleanup(attachments);
21 return;
22 }
23
24 try {
25 await waitForComposerDraftsLoaded();
26 const liveMessageIds = new Set(
27 Object.values(appAtomRegistry.get(threadOutboxManager.queuedMessagesByThreadKeyAtom))
28 .flat()
29 .map((message) => message.messageId),
30 );
31 const drafts = appAtomRegistry.get(composerDraftsAtom);
32 let clearedDraft = false;
33 for (const message of removedCreations) {
34 if (liveMessageIds.has(message.messageId)) {
35 continue;
36 }
37 const draftKey = `pending-task:${message.messageId}`;
38 const draft = drafts[draftKey];
39 if (draft === undefined) {
40 continue;
41 }
42 attachments.push(...draft.attachments);
43 clearComposerDraft(draftKey, { deferAttachmentCleanup: true });
44 clearedDraft = true;
45 }
46 if (clearedDraft) {
47 await flushComposerDrafts();
48 }
49 } catch (error) {
50 // The outbox removal is already durable. Keep the files and report the
51 // secondary cleanup failure without changing the successful result.
52 console.warn("[thread-outbox] failed to clean up removed pending task drafts", error);
53 return;
54 }
55
56 scheduleUnusedComposerAttachmentCleanup(attachments);
57}
58
59/**
60 * The only way a queued message leaves the outbox. Removal also releases the
61 * message's local attachment files (via the reference-counting sweep, so a
62 * file still referenced by a draft or another queued message survives).
63 * Keeping release inside the removal call means no call site can forget it.
64 *
65 * `expectedRevision` (from `threadOutboxRevision`) and `canRemove` make the
66 * removal a compare-and-set: when an edit was accepted or an editor takes the
67 * message, it stays queued, nothing is released, and this returns false.
68 */
69export async function removeThreadOutboxMessage(
70 message: QueuedThreadMessage,
71 expectedRevision?: number,
72 canRemove?: () => boolean,
73): Promise<boolean> {
74 const removed = await threadOutboxManager.remove(message, expectedRevision, canRemove);
75 if (removed === null) {
76 return false;
77 }
78 // The removed payload, not the caller's snapshot: an accepted update may
79 // have added files the snapshot never saw.
80 await cleanUpRemovedMessages([removed]);
81 return true;
82}
83
84/** Removes every queued message of an environment and releases their files. */
85export async function clearThreadOutboxEnvironment(environmentId: EnvironmentId): Promise<void> {
86 // clearEnvironment loads and merges persisted messages itself and reports
87 // what it actually removed, so the release set cannot miss messages a
88 // failed earlier hydration would have hidden.
89 const removed = await threadOutboxManager.clearEnvironment(environmentId);
90 await cleanUpRemovedMessages(removed);
91}