1import {
2 PROVIDER_SEND_TURN_MAX_FILE_BYTES,
3 WS_METHODS,
4 type AttachmentCreateUploadUrlInput,
5 type AttachmentCreateUploadUrlResult,
6 type AttachmentDeleteInput,
7 type EnvironmentId,
8} from "@t3tools/contracts";
9import type { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
10import * as Effect from "effect/Effect";
11
12import type { EnvironmentRegistry } from "../connection/registry.ts";
13import { requestGuarded } from "../rpc/client.ts";
14import { authorizeHubSignedUrl } from "./hubSignedUrl.ts";
15import {
16 createEnvironmentRpcCommand,
17 executeAtomQuery,
18 runAtomCommand,
19 squashAtomCommandFailure,
20 type AtomCommand,
21} from "./runtime.ts";
22
23/**
24 * RPC commands for pending chat attachment uploads. Mirrors
25 * `createAssetEnvironmentAtoms`: each client instantiates it with its own
26 * connection runtime (`attachmentEnvironment` in web and mobile).
27 */
28export function createAttachmentEnvironmentAtoms<R, E>(
29 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
30) {
31 return {
32 createUploadUrl: createEnvironmentRpcCommand(runtime, {
33 label: "environment-command:attachments:create-upload-url",
34 tag: WS_METHODS.attachmentsCreateUploadUrl,
35 execute: (input) =>
36 requestGuarded(WS_METHODS.attachmentsCreateUploadUrl, input).pipe(
37 Effect.flatMap(authorizeHubSignedUrl),
38 ),
39 }),
40 remove: createEnvironmentRpcCommand(runtime, {
41 label: "environment-command:attachments:delete",
42 tag: WS_METHODS.attachmentsDelete,
43 }),
44 };
45}
46
47/**
48 * Whether a failed asset lookup means the attachment no longer exists on the
49 * server, as opposed to a transient transport failure. Pending uploads expire,
50 * so this is the signal to upload the bytes again rather than retry the lookup.
51 *
52 * A structural `_tag` check rather than a schema check: the squashed cause of
53 * a failed RPC is not guaranteed to be a decoded error class instance, only a
54 * tagged value.
55 */
56export function isAssetAttachmentNotFoundFailure(error: unknown): boolean {
57 return (
58 typeof error === "object" &&
59 error !== null &&
60 "_tag" in error &&
61 error._tag === "AssetAttachmentNotFoundError"
62 );
63}
64
65export type PersistedAttachmentVerification =
66 | { readonly status: "verified" }
67 | { readonly status: "missing" }
68 | { readonly status: "failed"; readonly error: unknown };
69
70/**
71 * Checks that a previously uploaded pending attachment still exists on the
72 * server by minting an asset URL for it. `verified` means the send can reuse
73 * the stored bytes, `missing` means the upload expired and the bytes must be
74 * uploaded again, `failed` means the server could not be asked.
75 */
76export async function verifyPersistedAttachmentUpload<A, E>(input: {
77 readonly registry: AtomRegistry.AtomRegistry;
78 readonly createAssetUrl: (query: {
79 readonly environmentId: EnvironmentId;
80 readonly input: {
81 readonly resource: { readonly _tag: "attachment"; readonly attachmentId: string };
82 };
83 }) => Atom.Atom<AsyncResult.AsyncResult<A, E>>;
84 readonly environmentId: EnvironmentId;
85 readonly attachmentId: string;
86}): Promise<PersistedAttachmentVerification> {
87 const result = await executeAtomQuery(
88 input.registry,
89 input.createAssetUrl({
90 environmentId: input.environmentId,
91 input: { resource: { _tag: "attachment", attachmentId: input.attachmentId } },
92 }),
93 // `refresh` forces a server round trip: the asset URL query atom caches
94 // results (SWR), so a retry right after a transient failure would
95 // otherwise re-observe the cached failure and never ask the server.
96 { reportFailure: false, reportDefect: false, refresh: true },
97 );
98 if (result._tag === "Success") {
99 return { status: "verified" };
100 }
101 const error = squashAtomCommandFailure(result);
102 return isAssetAttachmentNotFoundFailure(error)
103 ? { status: "missing" }
104 : { status: "failed", error };
105}
106
107type AttachmentCreateUploadUrlCommand<E> = AtomCommand<
108 { readonly environmentId: EnvironmentId; readonly input: AttachmentCreateUploadUrlInput },
109 AttachmentCreateUploadUrlResult,
110 E
111>;
112
113type AttachmentRemoveCommand<E> = AtomCommand<
114 { readonly environmentId: EnvironmentId; readonly input: AttachmentDeleteInput },
115 unknown,
116 E
117>;
118
119/** Fire-and-forget delete of a pending upload the client no longer references. */
120export function deletePendingAttachmentUpload<E>(input: {
121 readonly registry: AtomRegistry.AtomRegistry;
122 readonly remove: AttachmentRemoveCommand<E>;
123 readonly environmentId: EnvironmentId;
124 readonly attachmentId: string;
125}): void {
126 void runAtomCommand(
127 input.registry,
128 input.remove,
129 { environmentId: input.environmentId, input: { attachmentId: input.attachmentId } },
130 { reportFailure: false, reportDefect: false },
131 );
132}
133
134/** A running byte transfer: resolves when the server accepted the bytes. */
135export interface AttachmentByteUpload {
136 readonly done: Promise<void>;
137 readonly abort: () => void;
138}
139
140export type AttachmentUploadCycleResult =
141 | { readonly status: "uploaded"; readonly attachmentId: string }
142 | { readonly status: "cancelled"; readonly attachmentId: string | null }
143 | {
144 readonly status: "failed";
145 readonly step: "mint" | "resolve-url" | "transfer";
146 readonly attachmentId: string | null;
147 readonly error: unknown;
148 };
149
150/**
151 * The platform-neutral upload cycle: mint a signed upload URL, resolve it
152 * against the environment's HTTP base, and hand the bytes to a
153 * platform-specific transport (XHR on web, `expo-file-system` on mobile).
154 *
155 * The cycle never deletes the minted pending upload on failure: callers keep
156 * the returned `attachmentId` and decide between retry and release. The one
157 * exception is `onMinted` returning `"cancel"`, where the caller already
158 * abandoned the upload and the fresh mint is deleted before returning.
159 */
160export async function runAttachmentUploadCycle<E, RE>(input: {
161 readonly registry: AtomRegistry.AtomRegistry;
162 readonly createUploadUrl: AttachmentCreateUploadUrlCommand<E>;
163 readonly remove: AttachmentRemoveCommand<RE>;
164 readonly environmentId: EnvironmentId;
165 readonly upload: AttachmentCreateUploadUrlInput;
166 readonly resolveUploadUrl: (relativeUrl: string) => string | null;
167 readonly transport: (url: string) => AttachmentByteUpload;
168 /** Observe the minted id (for cancellation bookkeeping) before bytes move. */
169 readonly onMinted?: (attachmentId: string) => "continue" | "cancel";
170 readonly onTransferStart?: (abort: () => void) => void;
171}): Promise<AttachmentUploadCycleResult> {
172 const minted = await runAtomCommand(
173 input.registry,
174 input.createUploadUrl,
175 { environmentId: input.environmentId, input: input.upload },
176 { reportFailure: false },
177 );
178 if (minted._tag !== "Success") {
179 return {
180 status: "failed",
181 step: "mint",
182 attachmentId: null,
183 error: squashAtomCommandFailure(minted),
184 };
185 }
186 const attachmentId = minted.value.attachmentId;
187 if (input.onMinted?.(attachmentId) === "cancel") {
188 deletePendingAttachmentUpload({
189 registry: input.registry,
190 remove: input.remove,
191 environmentId: input.environmentId,
192 attachmentId,
193 });
194 return { status: "cancelled", attachmentId };
195 }
196
197 const url = input.resolveUploadUrl(minted.value.relativeUrl);
198 if (!url) {
199 return {
200 status: "failed",
201 step: "resolve-url",
202 attachmentId,
203 error: new Error("The environment is not connected."),
204 };
205 }
206
207 const transfer = input.transport(url);
208 input.onTransferStart?.(transfer.abort);
209 try {
210 await transfer.done;
211 } catch (error) {
212 return { status: "failed", step: "transfer", attachmentId, error };
213 }
214 return { status: "uploaded", attachmentId };
215}
216
217/**
218 * The effective per-file byte limit for a server that advertises
219 * `capabilities.fileAttachments.maxUploadBytes`. The contract caps what a
220 * turn may reference, so a larger advertised value must not admit files the
221 * send would then refuse.
222 */
223export function clampFileAttachmentUploadBytes(advertisedMaxUploadBytes: number): number {
224 return Math.min(advertisedMaxUploadBytes, PROVIDER_SEND_TURN_MAX_FILE_BYTES);
225}
226
227/** "3.2 MB" / "48 KB" label for attachment rows. Never shows "0 KB". */
228export function formatAttachmentSize(sizeBytes: number): string {
229 return sizeBytes >= 1024 * 1024
230 ? `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`
231 : `${Math.max(1, Math.ceil(sizeBytes / 1024))} KB`;
232}
233
234/** User-facing rejection for a file over the effective upload limit. */
235export function fileAttachmentTooLargeMessage(name: string, maxUploadBytes: number): string {
236 const maxUploadSize =
237 maxUploadBytes >= 1024 * 1024 && maxUploadBytes % (1024 * 1024) === 0
238 ? `${maxUploadBytes / (1024 * 1024)} MB`
239 : maxUploadBytes >= 1024 && maxUploadBytes % 1024 === 0
240 ? `${maxUploadBytes / 1024} KB`
241 : `${maxUploadBytes} ${maxUploadBytes === 1 ? "byte" : "bytes"}`;
242 return `'${name}' exceeds the ${maxUploadSize} attachment limit.`;
243}