Files
multica/packages/core/drafts/draft-upload.ts
Naiyuan Qing 6c9a59cc14 refactor(uploads): widen handleUpload contract, fix interrupted doc drift (#6035)
Follow-up to #6025 (MUL-5391), addressing the two non-blocking cleanups
raised in review.

1. `CoordinatedUploads.handleUpload` was still typed as a one-arg
   function while the real `ContentEditor` contract is `(file, uploadId)`.
   Runtime was already safe (the implementation accepts `uploadId?`), but
   the exported interface erased the second parameter at the boundary, so
   a mock or hand-rolled caller could silently drop the editor-minted id
   and mint a second one — breaking the one-id link between the document
   node and the draft record. Widened the type and documented why the id
   must be threaded through.

2. #6025 changed `normalizeStoredUploads` to DROP persisted `uploading`
   records instead of coercing them to `interrupted`, but eleven comments
   across core and views still described the old coercion. Corrected them
   to state what the code does. `interrupted` is now produced by no code
   path at all; it stays in the union and is still accepted, rendered and
   dismissable because builds before this change persisted such records.
   Marked it LEGACY at the type and in the test that pins the behaviour.

No runtime behaviour change: comments, one type widening, one test comment.

Verified: pnpm typecheck (6/6); packages/core Vitest 1133 tests;
packages/views Vitest 3178 tests; git diff --check.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 15:17:40 +08:00

147 lines
5.9 KiB
TypeScript

import type { Attachment } from "../types";
/**
* Persistable upload placeholder + result (MUL-5181, L2).
*
* Before this, a composer draft stored only COMPLETED `Attachment` rows, so an
* upload that was still mid-flight when the composer closed had no persisted
* representation: reopening showed nothing, and a reload/app-restart left no
* trace that a file had ever been dropped. Modelled on Linear's local store
* (a first-class `uploads` record carrying `uploadId` + `uploadState`, linked
* to the draft), a `DraftUpload` lives in the persisted draft from the moment
* the file is picked, so the upload's lifecycle survives the component that
* started it.
*
* The `clientUploadId` is a client-minted id that ties the placeholder to the
* module-level {@link UploadCoordinator} running the actual request. It is NOT
* the server attachment id (which does not exist until the upload completes).
*/
export type UploadStatus = "uploading" | "uploaded" | "failed" | "interrupted";
interface DraftUploadBase {
/** Client-minted id, stable across the placeholder's whole lifecycle. */
clientUploadId: string;
filename: string;
/** Byte size, mirrored so a placeholder can render without the File. */
size: number;
contentType?: string;
}
/**
* A placeholder whose bytes are not (or no longer) resolvable to an attachment:
* - `uploading`: request in flight (owned by the coordinator).
* - `failed`: the request errored; keep it so the user sees the failure.
* - `interrupted`: LEGACY, no longer produced. Builds before MUL-5391 coerced
* a reload-surviving `uploading` record into this; that record is now
* dropped instead. Still accepted, rendered, and dismissable so blobs those
* builds persisted keep working.
*/
export interface PendingDraftUpload extends DraftUploadBase {
status: "uploading" | "failed" | "interrupted";
/** Present for `failed`; the surfaced error message. */
error?: string;
}
/** A completed upload carrying the full server attachment row. */
export interface UploadedDraftUpload extends DraftUploadBase {
status: "uploaded";
attachment: Attachment;
}
export type DraftUpload = PendingDraftUpload | UploadedDraftUpload;
/** True for a completed upload (narrows to {@link UploadedDraftUpload}). */
export function isUploaded(u: DraftUpload): u is UploadedDraftUpload {
return u.status === "uploaded";
}
/** The completed attachment rows, in order. The submit-bindable set. */
export function uploadedAttachments(uploads: readonly DraftUpload[]): Attachment[] {
const out: Attachment[] = [];
for (const u of uploads) {
if (u.status === "uploaded") out.push(u.attachment);
}
return out;
}
/** True while any upload is still in flight — the submit gate reads this. */
export function hasUploadingDraft(uploads: readonly DraftUpload[]): boolean {
return uploads.some((u) => u.status === "uploading");
}
/** Wrap a completed attachment as an uploaded placeholder. */
export function attachmentToDraftUpload(attachment: Attachment): UploadedDraftUpload {
return {
clientUploadId: attachment.id || attachment.url,
status: "uploaded",
filename: attachment.filename,
size: attachment.size_bytes,
contentType: attachment.content_type || undefined,
// `download_url` is minted for the current API response and may be a
// short-lived signed URL; draft uploads survive dialog closes and app
// restarts, so it is stripped here. `url`/`markdown_url` stay as the
// durable render/download paths, and content-editor's session merge
// backfills an empty download_url from the live upload result.
attachment: { ...attachment, download_url: "" },
};
}
function looksLikeAttachment(value: unknown): value is Attachment {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { id?: unknown }).id === "string" &&
typeof (value as { filename?: unknown }).filename === "string" &&
typeof (value as { url?: unknown }).url === "string"
);
}
function isDraftUploadShape(value: unknown): value is DraftUpload {
return (
typeof value === "object" &&
value !== null &&
typeof (value as { clientUploadId?: unknown }).clientUploadId === "string" &&
typeof (value as { status?: unknown }).status === "string"
);
}
/**
* Normalize a raw persisted array into `DraftUpload[]`:
* - already-`DraftUpload` entries are kept, EXCEPT any still in `uploading`,
* which are DROPPED — a reload/restart cannot resume the bytes, and no
* placeholder node survives in the body either (see the branch below).
* - bare `Attachment` rows (persisted by pre-L2 builds that stored only
* completed attachments) are wrapped as `uploaded`.
* - anything else is dropped.
*
* Runs on every load/rehydrate and on `set` writes, so the in-memory shape is
* always canonical regardless of which build wrote the persisted blob.
*/
export function normalizeStoredUploads(raw: unknown): DraftUpload[] {
if (!Array.isArray(raw)) return [];
const out: DraftUpload[] = [];
for (const item of raw) {
if (isDraftUploadShape(item)) {
if (item.status === "uploading") {
// Dropped, not coerced to `interrupted`. The bytes were never
// persisted, so this upload can neither resume nor be retried — and
// the document has no node for it either, because a placeholder is
// never serialised. Keeping a record no surface can act on only kept
// an otherwise-empty draft alive for the full TTL. The absence of the
// attachment in the body is what tells the user to attach it again.
continue;
} else if (item.status === "uploaded") {
// Trust the persisted attachment only if it still looks like one.
if (looksLikeAttachment((item as UploadedDraftUpload).attachment)) {
out.push(item);
}
} else {
out.push(item);
}
} else if (looksLikeAttachment(item)) {
out.push(attachmentToDraftUpload(item));
}
}
return out;
}