Upload feature (b): HTTP adapter (XHR multipart) + shell transport service

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-06-29 14:01:36 +02:00
co-authored by Claude Opus 4.8
parent 0e48f44773
commit c4bfe9d39b
2 changed files with 212 additions and 0 deletions
@@ -0,0 +1,87 @@
import { Injectable, inject } from '@angular/core';
import { UploadAdapter, XhrUploadRequest, XhrUploadHandle, UPLOAD_ABORTED } from './upload.adapter';
import { problemDetail } from '@shared/infrastructure/api-error';
import { UploadMsg, Upload } from './upload.machine';
/**
* Transport seam (PRD §6): how upload bytes leave the browser. The shipped impl is
* KeepaliveTransport (XHR for progress; poll-on-return covers the background gap).
* A ServiceWorkerTransport would set `backgroundSyncAvailable = true` and survive a
* closed tab — swapping it in touches only this interface.
*/
export interface UploadTransport {
readonly backgroundSyncAvailable: boolean;
send(req: XhrUploadRequest, onProgress: (pct: number) => void): XhrUploadHandle;
}
@Injectable({ providedIn: 'root' })
class KeepaliveTransport implements UploadTransport {
private adapter = inject(UploadAdapter);
readonly backgroundSyncAvailable = false; // no Service Worker registered in this POC
send(req: XhrUploadRequest, onProgress: (pct: number) => void) {
return this.adapter.xhrUpload(req, onProgress);
}
}
type Dispatch = (m: UploadMsg) => void;
/**
* Orchestrates upload effects: drives the transport and translates outcomes into
* domain messages. Holds no UI state itself — the host wizard's reducer owns it.
* Effects only; the reducer stays pure.
*/
@Injectable({ providedIn: 'root' })
export class UploadShellService {
private transport: UploadTransport = inject(KeepaliveTransport);
private adapter = inject(UploadAdapter);
private inflight = new Map<string, () => void>(); // localId → cancel
get backgroundSyncAvailable(): boolean {
return this.transport.backgroundSyncAvailable;
}
/** Start (or retry) an upload: queue → progress → complete/failed. */
upload(req: XhrUploadRequest, dispatch: Dispatch): void {
dispatch({ type: 'UploadQueued', localId: req.localId, backgroundSync: this.backgroundSyncAvailable });
const { done, cancel } = this.transport.send(req, (pct) =>
dispatch({ type: 'UploadProgress', localId: req.localId, progressPct: pct }),
);
this.inflight.set(req.localId, cancel);
done
.then(({ documentId }) => dispatch({ type: 'UploadComplete', localId: req.localId, documentId }))
.catch((e) => {
if (e !== UPLOAD_ABORTED) {
dispatch({ type: 'UploadFailed', localId: req.localId, reason: typeof e === 'string' ? e : '' });
}
})
.finally(() => this.inflight.delete(req.localId));
}
/** Optimistic delete: mark deleting → complete (removed) / failed (reverted). */
delete(localId: string, documentId: string, dispatch: Dispatch): void {
dispatch({ type: 'UploadDeleting', localId });
this.adapter
.deleteDocument(documentId)
.then(() => dispatch({ type: 'UploadDeleteComplete', localId }))
.catch((e) => dispatch({ type: 'UploadDeleteFailed', localId, reason: problemDetail(e, '') }));
}
/** Cancel any in-flight XHRs (e.g. when a category switches to post-delivery). */
cancel(localIds: string[]): void {
for (const id of localIds) {
this.inflight.get(id)?.();
this.inflight.delete(id);
}
}
/** Poll-on-return: ask the BFF which still-in-flight uploads have arrived. */
async pollReturning(uploads: Upload[], dispatch: Dispatch): Promise<void> {
const ids = uploads.map((u) => u.localId);
if (ids.length === 0) return;
const items = await this.adapter.status(ids);
const results = items
.filter((i) => i.status === 'complete' && i.documentId)
.map((i) => ({ localId: i.localId, success: true as const, documentId: i.documentId! }));
if (results.length > 0) dispatch({ type: 'BackgroundUploadsReturned', results });
}
}