Files
tech-log-frontend/src/application/ports/browser-transfer/resumable-upload.ts
T
DongHyeonkaandClaude Opus 5 4bff9ca151 chore: sync the frontend template from 4dc033c to 8157ad4
The product was materialized from the template at `4dc033c` and has stayed
on it through 43 template commits, so it was missing all three rounds of
adapter remediation — including files it never had, such as the shared
`abortable-operation` primitive and the `exact-snapshot` decoder that
later fixes are written against. Taking only the newest round was not
possible for that reason: the delta is coherent only as a whole.

The product had not touched `src/adapters` at all since materialization,
so the 140-file delta applied with a three-way merge and no conflicts.
`package.json` was the single overlap and merged cleanly: the product owns
`name`, the template contributed `check:adapter-inventory`,
`check:remediation-ledger` and the image-resolve-signal type fixture.
All 24 product-owned files — README, index.html, CI workflow, i18n
catalog, home page, generated schemas, evidence scripts, component and
visual snapshots — are byte-identical to `main`.

`template.lock.json` now pins the synced revision and tree.

Verified in this repository, not inherited from the template: six type
projects, lint, nine gates (adapter inventory, remediation ledger,
registries, diagnostics, realtime boundaries, architecture, browser
file/storage boundaries, optional recipes, documentation), the production
build, and 2,054 of 2,073 tests. The 19 failures are all in
`tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing
sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template
records; four suites that failed once under parallel load pass in
isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:04:58 +09:00

294 lines
8.3 KiB
TypeScript

import type { Result } from "../../result.ts";
import type { FileByteSource } from "../browser-file-storage/file.ts";
import type {
BrowserDataFailure,
BrowserDataResult,
TransferProgress,
} from "../browser-file-storage/shared.ts";
export const RESUMABLE_UPLOAD_PROTOCOL =
"PRESIGNED_MULTIPART_V1" as const;
export type ResumableUploadProtocol =
typeof RESUMABLE_UPLOAD_PROTOCOL;
/**
* Multipart uploads use a bounded part manifest instead of a whole-file
* ArrayBuffer. The digest is SHA-256 over the canonical ordered part metadata
* and SHA-256 part digests.
*/
export type UploadFileFingerprint = Readonly<{
algorithm: "SHA-256-PARTS-V1";
digestHex: string;
byteLength: number;
partSizeBytes: number;
partCount: number;
}>;
export type UploadPartDescriptor = Readonly<{
partNumber: number;
offset: number;
byteLength: number;
checksumSha256: string;
}>;
/**
* A non-authorizing server acknowledgement. It must be opaque, contain no PII,
* URL or credential, and be accepted only with the exact descriptor binding.
*/
export type UploadPartReceipt = UploadPartDescriptor &
Readonly<{
receiptToken: string;
}>;
export type PartitionDeleteOutcome =
| Readonly<{ state: "DELETED"; effect: "APPLIED" }>
| Readonly<{
state: "PENDING";
effect: "UNKNOWN";
reason: "BLOCKED_DEADLINE";
}>;
export interface UploadRangeReader {
readonly byteLength: number;
readRange(input: Readonly<{
offset: number;
length: number;
signal: AbortSignal;
}>): Promise<BrowserDataResult<Uint8Array>>;
}
/**
* FILE_BYTE_SOURCE supports existing FileByteSource implementations. It must
* be replayable for the fingerprint pass and transfer pass. RANGE_READER is
* preferred for concurrent uploads and OPFS/file-vault range adapters.
*/
export type ResumableUploadSource =
| Readonly<{
kind: "FILE_BYTE_SOURCE";
bytes: FileByteSource;
}>
| Readonly<{
kind: "RANGE_READER";
reader: UploadRangeReader;
}>;
export type ResumableUploadRequest = Readonly<{
/** Opaque, caller-stable operation key. It must not contain a file name. */
uploadKey: string;
/** Registry-approved backend purpose identifier, not user-provided text. */
purpose: string;
mediaType: string;
source: ResumableUploadSource;
signal: AbortSignal;
onProgress?: (progress: TransferProgress) => void;
}>;
export type QuarantinedUpload = Readonly<{
state: "QUARANTINED";
resourceId: string;
byteLength: number;
replayed: boolean;
}>;
export type UploadAbortOutcome = Readonly<{
state: "ABORTED" | "ORPHANED" | "ALREADY_COMPLETED" | "NOT_FOUND";
}>;
export interface ResumableUploadPort {
upload(
request: ResumableUploadRequest,
): Promise<BrowserDataResult<QuarantinedUpload>>;
abort(request: Readonly<{
uploadKey: string;
signal: AbortSignal;
}>): Promise<BrowserDataResult<UploadAbortOutcome>>;
}
/**
* Retry-After is transport metadata used only by the runtime. It is bounded
* before sleeping and is removed from the application-facing failure.
*/
export type UploadProviderFailure = BrowserDataFailure &
Readonly<{
retryAfterMs?: number;
}>;
export type UploadProviderResult<Value> = Result<
Value,
UploadProviderFailure
>;
export type UploadSession = Readonly<{
protocol: ResumableUploadProtocol;
sessionId: string;
requestBindingSha256: string;
fingerprint: UploadFileFingerprint;
partSizeBytes: number;
partCount: number;
maxConcurrency: number;
expiresAtEpochMs: number;
}>;
export type ActiveUploadStatus = Readonly<{
state: "ACTIVE";
session: UploadSession;
acceptedParts: readonly UploadPartReceipt[];
}>;
export type UploadSessionStatus =
| ActiveUploadStatus
| Readonly<{
state: "QUARANTINED";
session: UploadSession;
resourceId: string;
}>
| Readonly<{
state: "ABORTED" | "EXPIRED" | "NOT_FOUND";
protocol: ResumableUploadProtocol;
sessionId: string;
requestBindingSha256: string;
}>;
/**
* The capability value is intentionally generic. The presigned-transfer
* adapter owns its URL/method/header contract; this port neither duplicates
* that type nor permits it to enter a durable checkpoint.
*/
export type UploadPartCapability<Capability> = Readonly<{
capability: Capability;
uploadBindingSha256: string;
expiresAtEpochMs: number;
}>;
export interface ResumableUploadControlPlane<Capability> {
createSession(input: Readonly<{
protocol: ResumableUploadProtocol;
uploadKey: string;
purpose: string;
mediaType: string;
requestBindingSha256: string;
fingerprint: UploadFileFingerprint;
requestedPartSizeBytes: number;
requestedMaxConcurrency: number;
idempotencyKey: string;
signal: AbortSignal;
}>): Promise<UploadProviderResult<UploadSession>>;
getStatus(input: Readonly<{
protocol: ResumableUploadProtocol;
sessionId: string;
requestBindingSha256: string;
fingerprint: UploadFileFingerprint;
signal: AbortSignal;
}>): Promise<UploadProviderResult<UploadSessionStatus>>;
issuePartCapability(input: Readonly<{
protocol: ResumableUploadProtocol;
sessionId: string;
requestBindingSha256: string;
uploadBindingSha256: string;
fingerprint: UploadFileFingerprint;
mediaType: string;
part: UploadPartDescriptor;
idempotencyKey: string;
signal: AbortSignal;
}>): Promise<UploadProviderResult<UploadPartCapability<Capability>>>;
complete(input: Readonly<{
protocol: ResumableUploadProtocol;
sessionId: string;
requestBindingSha256: string;
fingerprint: UploadFileFingerprint;
orderedParts: readonly UploadPartReceipt[];
idempotencyKey: string;
signal: AbortSignal;
}>): Promise<UploadProviderResult<Readonly<{
state: "QUARANTINED";
protocol: ResumableUploadProtocol;
sessionId: string;
requestBindingSha256: string;
fingerprint: UploadFileFingerprint;
resourceId: string;
}>>>;
abort(input: Readonly<{
protocol: ResumableUploadProtocol;
sessionId: string;
requestBindingSha256: string;
idempotencyKey: string;
signal: AbortSignal;
}>): Promise<UploadProviderResult<Readonly<{
state: "ABORTED" | "NOT_FOUND" | "EXPIRED" | "ALREADY_COMPLETED";
}>>>;
}
export interface UploadPartExecutor<Capability> {
uploadPart(input: Readonly<{
protocol: ResumableUploadProtocol;
capability: Capability;
sessionId: string;
requestBindingSha256: string;
uploadBindingSha256: string;
fingerprint: UploadFileFingerprint;
mediaType: string;
part: UploadPartDescriptor;
bytes: Uint8Array;
idempotencyKey: string;
signal: AbortSignal;
}>): Promise<UploadProviderResult<UploadPartReceipt>>;
}
/**
* Durable, non-secret recovery state. Implementations must reject any unknown
* property so a signed URL, authorization header or user metadata cannot be
* smuggled into persistence.
*/
export type ResumableUploadCheckpoint = Readonly<{
schemaVersion: 1;
protocol: ResumableUploadProtocol;
revision: number;
state: "ACTIVE" | "ABORT_PENDING";
uploadKey: string;
requestBindingSha256: string;
fingerprint: UploadFileFingerprint;
sessionId: string;
sessionExpiresAtEpochMs: number;
sessionMaxConcurrency: number;
acceptedParts: readonly UploadPartReceipt[];
updatedAtEpochMs: number;
}>;
export interface ResumableUploadCheckpointStore {
read(
uploadKey: string,
signal?: AbortSignal,
): Promise<BrowserDataResult<ResumableUploadCheckpoint | null>>;
compareAndSwap(input: Readonly<{
expectedRevision: number | null;
checkpoint: ResumableUploadCheckpoint;
signal?: AbortSignal;
}>): Promise<BrowserDataResult<ResumableUploadCheckpoint>>;
remove(input: Readonly<{
uploadKey: string;
expectedRevision: number;
signal?: AbortSignal;
}>): Promise<BrowserDataResult<void>>;
close(): void;
}
export interface ResumableUploadCheckpointAdmin {
/**
* Account/logout lifecycle operation for this already-bound opaque partition.
* The adapter closes its connection before deletion and bounds blocked waits.
*/
/**
* BT-UP-03. An IndexedDB `deleteDatabase()` request cannot be cancelled once
* dispatched, so a blocked deadline is not evidence that nothing happened.
* `PENDING` reports the effect honestly as `UNKNOWN`; only pre-dispatch
* problems are ordinary failures.
*/
deletePartition(
signal?: AbortSignal,
): Promise<BrowserDataResult<PartitionDeleteOutcome>>;
}