import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { clampFileAttachmentUploadBytes, fileAttachmentTooLargeMessage, isAssetAttachmentNotFoundFailure, runAttachmentUploadCycle, verifyPersistedAttachmentUpload, } from "@t3tools/client-runtime/state/attachments"; import { runAtomCommand, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { ChatFileAttachment, EnvironmentId, UploadChatImageAttachment, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { appAtomRegistry } from "../state/atom-registry"; import { assetEnvironment } from "../state/assets"; import { attachmentEnvironment } from "../state/attachments"; import { environmentSession } from "../state/session"; import { resolveOwnedComposerAttachmentFileUri } from "./composerAttachmentFiles"; import { toUploadChatImageAttachments, type DraftComposerAttachment } from "./composerImages"; /** * This module owns the server side of a composer attachment's lifecycle. * `prepareTurnAttachments` acquires pending uploads (verifying and reusing * persisted ones), hands the uploaded ids back to the attachment's durable * owner (queued outbox message or composer draft), and returns a release * handle for after the turn consumed the bytes. Nothing outside this module * mints or deletes pending uploads. The local-file side of the lifecycle is * owned by `removeThreadOutboxMessage` / the composer draft mutators, which * release files through `releaseUnusedComposerAttachmentFiles`. */ export type UploadedMobileAttachment = UploadChatImageAttachment | ChatFileAttachment; export function validateDraftFileAttachments(input: { readonly attachments: ReadonlyArray; readonly serverConfig: { readonly environment: { readonly capabilities: { readonly attachmentUploads?: boolean; readonly fileAttachments?: { readonly maxUploadBytes: number }; }; }; } | null; }): string | null { const files = input.attachments.filter((attachment) => attachment.type === "file"); if (files.length === 0) return null; if (input.serverConfig === null) return "Server attachment support is still loading."; const capabilities = input.serverConfig.environment.capabilities; if (capabilities.attachmentUploads !== true || capabilities.fileAttachments === undefined) { return "This server does not support file attachments."; } const maxBytes = clampFileAttachmentUploadBytes(capabilities.fileAttachments.maxUploadBytes); const oversized = files.find((attachment) => attachment.sizeBytes > maxBytes); return oversized ? fileAttachmentTooLargeMessage(oversized.name, maxBytes) : null; } /** Keep uploaded file ids on durable drafts so a later send can reuse their bytes. */ export function withUploadedMobileAttachmentReferences(input: { readonly environmentId: EnvironmentId; readonly attachments: ReadonlyArray; readonly uploadedAttachments: ReadonlyArray; }): ReadonlyArray { return input.attachments.map((attachment, index) => { const uploaded = input.uploadedAttachments[index]; if ( attachment.type !== "file" || uploaded?.type !== "file" || (attachment.uploadedAttachmentId === uploaded.id && attachment.uploadEnvironmentId === input.environmentId) ) { return attachment; } return { ...attachment, uploadedAttachmentId: uploaded.id, uploadEnvironmentId: input.environmentId, }; }); } /** * Deletes pending uploads the client no longer references. Every delete result * is inspected; failed deletes are retried once and a persistent failure * throws, so a caller can never silently leak the outcome. (The server also * expires pending uploads, so a leaked id self-heals eventually.) */ export async function releasePendingAttachmentUploads( environmentId: EnvironmentId, attachmentIds: ReadonlyArray, ): Promise { const deleteOnce = async (attachmentId: string): Promise => { const result = await runAtomCommand( appAtomRegistry, attachmentEnvironment.remove, { environmentId, input: { attachmentId } }, { reportFailure: false, reportDefect: false }, ); return ( result._tag === "Success" || isAssetAttachmentNotFoundFailure(squashAtomCommandFailure(result)) ); }; const failedAttachmentIds: string[] = []; for (const attachmentId of attachmentIds) { if (!(await deleteOnce(attachmentId)) && !(await deleteOnce(attachmentId))) { failedAttachmentIds.push(attachmentId); } } if (failedAttachmentIds.length > 0) { throw new Error( `Could not delete ${failedAttachmentIds.length} pending attachment upload(s): ${failedAttachmentIds.join(", ")}.`, ); } } async function releaseCreatedUploadsQuietly( environmentId: EnvironmentId, attachmentIds: ReadonlyArray, ): Promise { try { await releasePendingAttachmentUploads(environmentId, attachmentIds); } catch (error) { // The original failure must propagate; the leaked pending uploads expire // on the server. console.warn("[attachments] could not delete abandoned pending uploads", error); } } export interface PreparedTurnAttachments { readonly status: "ready"; /** Wire attachments for `startTurn`, in the original composer order. */ readonly attachments: ReadonlyArray; /** Composer attachments annotated with the uploaded pending ids. */ readonly draftAttachments: ReadonlyArray; /** Every pending upload backing this turn (reused and newly minted). */ readonly pendingAttachmentIds: ReadonlyArray; /** Deletes all pending uploads once the delivered turn holds the bytes. */ readonly releaseUploads: () => Promise; } export type PrepareTurnAttachmentsResult = | PreparedTurnAttachments | { readonly status: "abandoned" }; async function uploadFileBytes( attachment: Extract, url: string, ): Promise { const { File, Paths, UploadType } = await import("expo-file-system"); const fileUri = resolveOwnedComposerAttachmentFileUri(attachment.fileUri, Paths.document.uri) ?? attachment.fileUri; const result = await new File(fileUri).upload(url, { httpMethod: "POST", uploadType: UploadType.BINARY_CONTENT, headers: { "Content-Type": attachment.mimeType }, }); if (result.status < 200 || result.status >= 300) { throw new Error(`Upload failed for '${attachment.name}' (${result.status}).`); } } /** * Acquires server-side uploads for one turn's attachments and persists the * uploaded ids into the attachments' durable owner. * * `persistUploadedReferences` runs once the bytes are on the server and only * when new ids appeared. It must write the annotated attachments into the * owner (queued message or draft) so a retry after a crash reuses the bytes. * Returning `"abandon"` (owner no longer wants the send) or throwing deletes * the pending uploads this call minted, so the owner cannot leak them. */ export async function prepareTurnAttachments(input: { readonly environmentId: EnvironmentId; readonly attachments: ReadonlyArray; readonly persistUploadedReferences?: ( draftAttachments: ReadonlyArray, ) => Promise<"persisted" | "abandon">; }): Promise { const { environmentId } = input; const files = input.attachments.filter((attachment) => attachment.type === "file"); const ready = ( attachments: ReadonlyArray, pendingAttachmentIds: ReadonlyArray, draftAttachments: ReadonlyArray, ): PreparedTurnAttachments => ({ status: "ready", attachments, draftAttachments, pendingAttachmentIds, releaseUploads: () => releasePendingAttachmentUploads(environmentId, pendingAttachmentIds), }); if (files.length === 0) { return ready( toUploadChatImageAttachments( input.attachments.filter((attachment) => attachment.type === "image"), ), [], input.attachments, ); } const connection = appAtomRegistry.get( environmentSession.preparedConnectionValueAtom(environmentId), ); if (Option.isNone(connection)) { throw new Error("The environment is not connected."); } const uploadedAttachments: UploadedMobileAttachment[] = []; const pendingAttachmentIds: string[] = []; const createdAttachmentIds: string[] = []; try { for (const attachment of input.attachments) { if (attachment.type === "image") { uploadedAttachments.push(...toUploadChatImageAttachments([attachment])); continue; } // Reuse the bytes from a previous attempt when their pending upload is // still alive on this environment. if ( attachment.uploadEnvironmentId === environmentId && attachment.uploadedAttachmentId !== undefined ) { const verification = await verifyPersistedAttachmentUpload({ registry: appAtomRegistry, createAssetUrl: assetEnvironment.createUrl, environmentId, attachmentId: attachment.uploadedAttachmentId, }); if (verification.status === "failed") { throw verification.error; } if (verification.status === "verified") { pendingAttachmentIds.push(attachment.uploadedAttachmentId); uploadedAttachments.push({ type: "file", id: attachment.uploadedAttachmentId, name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, }); continue; } // "missing": the pending upload expired, upload the bytes again. } const result = await runAttachmentUploadCycle({ registry: appAtomRegistry, createUploadUrl: attachmentEnvironment.createUploadUrl, remove: attachmentEnvironment.remove, environmentId, upload: { type: "file", name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, }, // Read the connection at transfer time: the environment may have // reconnected on a new base URL since this cycle started. resolveUploadUrl: (relativeUrl) => { const currentConnection = appAtomRegistry.get( environmentSession.preparedConnectionValueAtom(environmentId), ); return Option.isNone(currentConnection) ? null : resolveAssetUrl(currentConnection.value.httpBaseUrl, relativeUrl); }, transport: (url) => ({ done: uploadFileBytes(attachment, url), // expo-file-system uploads cannot abort mid-flight. abort: () => {}, }), onMinted: (attachmentId) => { pendingAttachmentIds.push(attachmentId); createdAttachmentIds.push(attachmentId); return "continue"; }, }); if (result.status !== "uploaded") { throw result.status === "failed" && result.error !== undefined ? result.error : new Error(`Upload failed for '${attachment.name}'.`); } uploadedAttachments.push({ type: "file", id: result.attachmentId, name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, }); } const draftAttachments = withUploadedMobileAttachmentReferences({ environmentId, attachments: input.attachments, uploadedAttachments, }); const referencesChanged = draftAttachments.some( (attachment, index) => attachment !== input.attachments[index], ); if (referencesChanged && input.persistUploadedReferences) { if ((await input.persistUploadedReferences(draftAttachments)) === "abandon") { await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds); return { status: "abandoned" }; } } return ready(uploadedAttachments, pendingAttachmentIds, draftAttachments); } catch (error) { await releaseCreatedUploadsQuietly(environmentId, createdAttachmentIds); throw error; } }