236 lines · 8.5 KB · TypeScript
RawDownload
import {  PROVIDER_SEND_TURN_MAX_FILE_BYTES,  WS_METHODS,  type AttachmentCreateUploadUrlInput,  type AttachmentCreateUploadUrlResult,  type AttachmentDeleteInput,  type EnvironmentId,} from "@t3tools/contracts";import type { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts";import {  createEnvironmentRpcCommand,  executeAtomQuery,  runAtomCommand,  squashAtomCommandFailure,  type AtomCommand,} from "./runtime.ts"; /** * RPC commands for pending chat attachment uploads. Mirrors * `createAssetEnvironmentAtoms`: each client instantiates it with its own * connection runtime (`attachmentEnvironment` in web and mobile). */export function createAttachmentEnvironmentAtoms<R, E>(  runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,) {  return {    createUploadUrl: createEnvironmentRpcCommand(runtime, {      label: "environment-command:attachments:create-upload-url",      tag: WS_METHODS.attachmentsCreateUploadUrl,    }),    remove: createEnvironmentRpcCommand(runtime, {      label: "environment-command:attachments:delete",      tag: WS_METHODS.attachmentsDelete,    }),  };} /** * Whether a failed asset lookup means the attachment no longer exists on the * server, as opposed to a transient transport failure. Pending uploads expire, * so this is the signal to upload the bytes again rather than retry the lookup. * * A structural `_tag` check rather than a schema check: the squashed cause of * a failed RPC is not guaranteed to be a decoded error class instance, only a * tagged value. */export function isAssetAttachmentNotFoundFailure(error: unknown): boolean {  return (    typeof error === "object" &&    error !== null &&    "_tag" in error &&    error._tag === "AssetAttachmentNotFoundError"  );} export type PersistedAttachmentVerification =  | { readonly status: "verified" }  | { readonly status: "missing" }  | { readonly status: "failed"; readonly error: unknown }; /** * Checks that a previously uploaded pending attachment still exists on the * server by minting an asset URL for it. `verified` means the send can reuse * the stored bytes, `missing` means the upload expired and the bytes must be * uploaded again, `failed` means the server could not be asked. */export async function verifyPersistedAttachmentUpload<A, E>(input: {  readonly registry: AtomRegistry.AtomRegistry;  readonly createAssetUrl: (query: {    readonly environmentId: EnvironmentId;    readonly input: {      readonly resource: { readonly _tag: "attachment"; readonly attachmentId: string };    };  }) => Atom.Atom<AsyncResult.AsyncResult<A, E>>;  readonly environmentId: EnvironmentId;  readonly attachmentId: string;}): Promise<PersistedAttachmentVerification> {  const result = await executeAtomQuery(    input.registry,    input.createAssetUrl({      environmentId: input.environmentId,      input: { resource: { _tag: "attachment", attachmentId: input.attachmentId } },    }),    // `refresh` forces a server round trip: the asset URL query atom caches    // results (SWR), so a retry right after a transient failure would    // otherwise re-observe the cached failure and never ask the server.    { reportFailure: false, reportDefect: false, refresh: true },  );  if (result._tag === "Success") {    return { status: "verified" };  }  const error = squashAtomCommandFailure(result);  return isAssetAttachmentNotFoundFailure(error)    ? { status: "missing" }    : { status: "failed", error };} type AttachmentCreateUploadUrlCommand<E> = AtomCommand<  { readonly environmentId: EnvironmentId; readonly input: AttachmentCreateUploadUrlInput },  AttachmentCreateUploadUrlResult,  E>; type AttachmentRemoveCommand<E> = AtomCommand<  { readonly environmentId: EnvironmentId; readonly input: AttachmentDeleteInput },  unknown,  E>; /** Fire-and-forget delete of a pending upload the client no longer references. */export function deletePendingAttachmentUpload<E>(input: {  readonly registry: AtomRegistry.AtomRegistry;  readonly remove: AttachmentRemoveCommand<E>;  readonly environmentId: EnvironmentId;  readonly attachmentId: string;}): void {  void runAtomCommand(    input.registry,    input.remove,    { environmentId: input.environmentId, input: { attachmentId: input.attachmentId } },    { reportFailure: false, reportDefect: false },  );} /** A running byte transfer: resolves when the server accepted the bytes. */export interface AttachmentByteUpload {  readonly done: Promise<void>;  readonly abort: () => void;} export type AttachmentUploadCycleResult =  | { readonly status: "uploaded"; readonly attachmentId: string }  | { readonly status: "cancelled"; readonly attachmentId: string | null }  | {      readonly status: "failed";      readonly step: "mint" | "resolve-url" | "transfer";      readonly attachmentId: string | null;      readonly error: unknown;    }; /** * The platform-neutral upload cycle: mint a signed upload URL, resolve it * against the environment's HTTP base, and hand the bytes to a * platform-specific transport (XHR on web, `expo-file-system` on mobile). * * The cycle never deletes the minted pending upload on failure: callers keep * the returned `attachmentId` and decide between retry and release. The one * exception is `onMinted` returning `"cancel"`, where the caller already * abandoned the upload and the fresh mint is deleted before returning. */export async function runAttachmentUploadCycle<E, RE>(input: {  readonly registry: AtomRegistry.AtomRegistry;  readonly createUploadUrl: AttachmentCreateUploadUrlCommand<E>;  readonly remove: AttachmentRemoveCommand<RE>;  readonly environmentId: EnvironmentId;  readonly upload: AttachmentCreateUploadUrlInput;  readonly resolveUploadUrl: (relativeUrl: string) => string | null;  readonly transport: (url: string) => AttachmentByteUpload;  /** Observe the minted id (for cancellation bookkeeping) before bytes move. */  readonly onMinted?: (attachmentId: string) => "continue" | "cancel";  readonly onTransferStart?: (abort: () => void) => void;}): Promise<AttachmentUploadCycleResult> {  const minted = await runAtomCommand(    input.registry,    input.createUploadUrl,    { environmentId: input.environmentId, input: input.upload },    { reportFailure: false },  );  if (minted._tag !== "Success") {    return {      status: "failed",      step: "mint",      attachmentId: null,      error: squashAtomCommandFailure(minted),    };  }  const attachmentId = minted.value.attachmentId;  if (input.onMinted?.(attachmentId) === "cancel") {    deletePendingAttachmentUpload({      registry: input.registry,      remove: input.remove,      environmentId: input.environmentId,      attachmentId,    });    return { status: "cancelled", attachmentId };  }   const url = input.resolveUploadUrl(minted.value.relativeUrl);  if (!url) {    return {      status: "failed",      step: "resolve-url",      attachmentId,      error: new Error("The environment is not connected."),    };  }   const transfer = input.transport(url);  input.onTransferStart?.(transfer.abort);  try {    await transfer.done;  } catch (error) {    return { status: "failed", step: "transfer", attachmentId, error };  }  return { status: "uploaded", attachmentId };} /** * The effective per-file byte limit for a server that advertises * `capabilities.fileAttachments.maxUploadBytes`. The contract caps what a * turn may reference, so a larger advertised value must not admit files the * send would then refuse. */export function clampFileAttachmentUploadBytes(advertisedMaxUploadBytes: number): number {  return Math.min(advertisedMaxUploadBytes, PROVIDER_SEND_TURN_MAX_FILE_BYTES);} /** "3.2 MB" / "48 KB" label for attachment rows. Never shows "0 KB". */export function formatAttachmentSize(sizeBytes: number): string {  return sizeBytes >= 1024 * 1024    ? `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`    : `${Math.max(1, Math.ceil(sizeBytes / 1024))} KB`;} /** User-facing rejection for a file over the effective upload limit. */export function fileAttachmentTooLargeMessage(name: string, maxUploadBytes: number): string {  const maxUploadSize =    maxUploadBytes >= 1024 * 1024 && maxUploadBytes % (1024 * 1024) === 0      ? `${maxUploadBytes / (1024 * 1024)} MB`      : maxUploadBytes >= 1024 && maxUploadBytes % 1024 === 0        ? `${maxUploadBytes / 1024} KB`        : `${maxUploadBytes} ${maxUploadBytes === 1 ? "byte" : "bytes"}`;  return `'${name}' exceeds the ${maxUploadSize} attachment limit.`;}