@gentleduck/upload 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/index.d.ts +1417 -0
  2. package/dist/index.js +6 -0
  3. package/package.json +43 -0
  4. package/src/core/client/config.types.ts +77 -0
  5. package/src/core/client/index.ts +4 -0
  6. package/src/core/client/plugins.types.ts +46 -0
  7. package/src/core/client/retry.types.ts +6 -0
  8. package/src/core/client/uploadClient.ts +19 -0
  9. package/src/core/contracts/backendApi.ts +62 -0
  10. package/src/core/contracts/cursor.types.ts +23 -0
  11. package/src/core/contracts/errors.types.ts +67 -0
  12. package/src/core/contracts/fingerprint.types.ts +18 -0
  13. package/src/core/contracts/index.ts +8 -0
  14. package/src/core/contracts/intent.types.ts +39 -0
  15. package/src/core/contracts/result.types.ts +11 -0
  16. package/src/core/contracts/strategy.types.ts +45 -0
  17. package/src/core/contracts/transport/index.ts +2 -0
  18. package/src/core/contracts/transport/transport.libs.ts +100 -0
  19. package/src/core/contracts/transport/transport.ts +166 -0
  20. package/src/core/contracts/transport/transport.types.ts +44 -0
  21. package/src/core/engine/commands.types.ts +17 -0
  22. package/src/core/engine/event-map.types.ts +45 -0
  23. package/src/core/engine/index.ts +8 -0
  24. package/src/core/engine/internal-events.types.ts +222 -0
  25. package/src/core/engine/outcome.types.ts +9 -0
  26. package/src/core/engine/progress.types.ts +15 -0
  27. package/src/core/engine/reducer.ts +435 -0
  28. package/src/core/engine/store/handlers/add-file.ts +103 -0
  29. package/src/core/engine/store/handlers/cancel.ts +56 -0
  30. package/src/core/engine/store/handlers/clean-up.ts +66 -0
  31. package/src/core/engine/store/handlers/create-intent.ts +76 -0
  32. package/src/core/engine/store/handlers/finalize.ts +59 -0
  33. package/src/core/engine/store/handlers/index.ts +7 -0
  34. package/src/core/engine/store/handlers/pause.ts +16 -0
  35. package/src/core/engine/store/handlers/run-upload.ts +192 -0
  36. package/src/core/engine/store/index.ts +123 -0
  37. package/src/core/engine/store/store.dispatch.ts +78 -0
  38. package/src/core/engine/store/store.libs.ts +169 -0
  39. package/src/core/engine/store/store.runtime.ts +336 -0
  40. package/src/core/engine/store/store.schedule.ts +116 -0
  41. package/src/core/engine/store/store.types.ts +250 -0
  42. package/src/core/engine/validation/file.ts +77 -0
  43. package/src/core/engine/validation/index.ts +3 -0
  44. package/src/core/engine/validation/intent.ts +55 -0
  45. package/src/core/engine/validation/list.ts +50 -0
  46. package/src/core/index.ts +5 -0
  47. package/src/core/persistence/adapters.indexeddb.ts +92 -0
  48. package/src/core/persistence/adapters.local.ts +22 -0
  49. package/src/core/persistence/adapters.memory.ts +15 -0
  50. package/src/core/persistence/index.ts +5 -0
  51. package/src/core/persistence/persistence.ts +194 -0
  52. package/src/core/persistence/persistence.types.ts +66 -0
  53. package/src/core/utils/async.ts +3 -0
  54. package/src/core/utils/constants.ts +32 -0
  55. package/src/core/utils/emitter/emitter.types.ts +23 -0
  56. package/src/core/utils/emitter/index.ts +68 -0
  57. package/src/core/utils/fingerprint.ts +20 -0
  58. package/src/core/utils/guards.ts +3 -0
  59. package/src/core/utils/id.ts +3 -0
  60. package/src/core/utils/index.ts +6 -0
  61. package/src/index.ts +3 -0
  62. package/src/react/index.ts +2 -0
  63. package/src/react/upload-provider.tsx +96 -0
  64. package/src/react/use-uploader.tsx +115 -0
  65. package/src/strategies/index.ts +15 -0
  66. package/src/strategies/multipart/index.ts +243 -0
  67. package/src/strategies/post/index.ts +93 -0
  68. package/src/strategies/registry/index.ts +30 -0
@@ -0,0 +1,100 @@
1
+ import { isRecord } from '../../utils/guards'
2
+
3
+ // ============================================================================
4
+ // ABORT HANDLING UTILITIES
5
+ // ============================================================================
6
+
7
+ export function abortReason(signal?: AbortSignal): unknown {
8
+ return signal?.reason
9
+ }
10
+
11
+ export function normalizeAbortReason(r: unknown): 'pause' | 'cancel' | 'unknown' {
12
+ if (!r) return 'unknown'
13
+
14
+ // Direct string match
15
+ if (typeof r === 'string') {
16
+ if (r === 'pause' || r === 'cancel') return r
17
+ return 'unknown'
18
+ }
19
+
20
+ // Object with 'reason' property (from controller.abort({ reason: 'pause' }))
21
+ if (isRecord(r) && 'reason' in r) {
22
+ const v = r.reason
23
+ if (v === 'pause' || v === 'cancel') return v
24
+ }
25
+
26
+ // Object with 'kind' property (alternative format)
27
+ if (isRecord(r) && 'kind' in r) {
28
+ const v = r.kind
29
+ if (v === 'pause' || v === 'cancel') return v
30
+ }
31
+
32
+ return 'unknown'
33
+ }
34
+
35
+ export class UploadAbortError extends Error {
36
+ readonly code: 'aborted' = 'aborted'
37
+ reason: 'pause' | 'cancel' | 'unknown'
38
+
39
+ constructor(reason: unknown) {
40
+ super('Upload aborted')
41
+ this.reason = normalizeAbortReason(reason)
42
+ }
43
+ }
44
+
45
+ export function makeAbortError(reason: unknown): UploadAbortError {
46
+ return new UploadAbortError(reason)
47
+ }
48
+
49
+ // ============================================================================
50
+ // ERROR HANDLING HELPERS
51
+ // ============================================================================
52
+
53
+ /**
54
+ * Creates an error for network failures, including CORS detection.
55
+ *
56
+ * @param xhr - XMLHttpRequest instance to check status
57
+ * @param defaultMessage - Default error message if status is non-zero
58
+ *
59
+ * @returns {Error} Appropriate error message based on XHR status
60
+ */
61
+ export function createNetworkError(xhr: XMLHttpRequest, defaultMessage: string): Error {
62
+ // Status 0 typically indicates CORS failure or network error
63
+ if (xhr.status === 0) {
64
+ return new Error(
65
+ 'CORS error: The upload server does not allow requests from this origin. Please check CORS configuration.',
66
+ )
67
+ }
68
+ return new Error(`${defaultMessage} (status: ${xhr.status})`)
69
+ }
70
+
71
+ // ============================================================================
72
+ // RESPONSE PARSING
73
+ // ============================================================================
74
+
75
+ /**
76
+ * Parses XHR response headers into a key-value object.
77
+ * Headers are normalized to lowercase keys.
78
+ *
79
+ * @param xhr - XMLHttpRequest instance
80
+ *
81
+ * @returns {Record<string, string>} Parsed headers object
82
+ */
83
+ export function parseHeaders(xhr: XMLHttpRequest): Record<string, string> {
84
+ const headers: Record<string, string> = {}
85
+ const headerStr = xhr.getAllResponseHeaders()
86
+
87
+ headerStr
88
+ .split('\r\n')
89
+ .map((l) => l.trim())
90
+ .filter(Boolean)
91
+ .forEach((line) => {
92
+ const idx = line.indexOf(':')
93
+ if (idx <= 0) return
94
+ const key = line.slice(0, idx).trim().toLowerCase()
95
+ const value = line.slice(idx + 1).trim()
96
+ if (key) headers[key] = value
97
+ })
98
+
99
+ return headers
100
+ }
@@ -0,0 +1,166 @@
1
+ import { abortReason, createNetworkError, makeAbortError, parseHeaders } from './transport.libs'
2
+ import type { UploadTransport } from './transport.types'
3
+
4
+ type XhrCommonArgs = {
5
+ url: string
6
+ signal?: AbortSignal
7
+ headers?: Record<string, string>
8
+ onProgress?: (loaded: number, total: number) => void
9
+ }
10
+
11
+ function isAbsoluteHttpUrl(url: string) {
12
+ return /^https?:\/\//i.test(url)
13
+ }
14
+
15
+ function readEtag(xhr: XMLHttpRequest) {
16
+ // Needs CORS expose: Access-Control-Expose-Headers: ETag
17
+ const raw = xhr.getResponseHeader('ETag') ?? xhr.getResponseHeader('etag')
18
+ if (!raw) return undefined
19
+ return raw.replace(/"/g, '')
20
+ }
21
+
22
+ function xhrRequest(args: {
23
+ method: 'POST' | 'PUT' | 'PATCH'
24
+ url: string
25
+ body: Document | BodyInit | null
26
+ signal?: AbortSignal
27
+ headers?: Record<string, string>
28
+ onProgress?: (loaded: number, total: number) => void
29
+ }) {
30
+ return new Promise<{ headers: Record<string, string>; etag?: string }>((resolve, reject) => {
31
+ if (!args.url || typeof args.url !== 'string') {
32
+ reject(new Error(`UploadTransport: missing url`))
33
+ return
34
+ }
35
+ if (!isAbsoluteHttpUrl(args.url)) {
36
+ reject(new Error(`UploadTransport: expected absolute URL, got: ${String(args.url)}`))
37
+ return
38
+ }
39
+
40
+ const xhr = new XMLHttpRequest()
41
+ let settled = false
42
+
43
+ const settle = (fn: () => void) => {
44
+ if (settled) return
45
+ settled = true
46
+ cleanup()
47
+ fn()
48
+ }
49
+
50
+ const onAbort = () => {
51
+ try {
52
+ xhr.abort()
53
+ } finally {
54
+ settle(() => reject(makeAbortError(abortReason(args.signal))))
55
+ }
56
+ }
57
+
58
+ const cleanup = () => {
59
+ if (args.signal) args.signal.removeEventListener('abort', onAbort)
60
+
61
+ xhr.upload.onprogress = null
62
+ xhr.onload = null
63
+ xhr.onerror = null
64
+ xhr.onabort = null
65
+ }
66
+
67
+ if (args.signal?.aborted) {
68
+ settle(() => reject(makeAbortError(abortReason(args.signal))))
69
+ return
70
+ }
71
+
72
+ if (args.signal) args.signal.addEventListener('abort', onAbort, { once: true })
73
+
74
+ if (args.onProgress) {
75
+ xhr.upload.onprogress = (e) => {
76
+ if (e.lengthComputable) args.onProgress?.(e.loaded, e.total)
77
+ }
78
+ }
79
+
80
+ xhr.onload = () => {
81
+ const status = xhr.status
82
+ // S3 multipart UploadPart returns 200, tus PATCH returns 204 typically.
83
+ if (status >= 200 && status < 300) {
84
+ settle(() =>
85
+ resolve({
86
+ headers: parseHeaders(xhr),
87
+ etag: readEtag(xhr),
88
+ }),
89
+ )
90
+ } else {
91
+ settle(() => reject(new Error(`Upload failed with status ${status}`)))
92
+ }
93
+ }
94
+
95
+ xhr.onerror = () => settle(() => reject(createNetworkError(xhr, 'Network error during upload')))
96
+
97
+ xhr.onabort = () =>
98
+ settle(() => reject(makeAbortError(args.signal?.aborted ? abortReason(args.signal) : 'unknown')))
99
+
100
+ xhr.open(args.method, args.url)
101
+
102
+ if (args.headers) {
103
+ for (const [k, v] of Object.entries(args.headers)) {
104
+ xhr.setRequestHeader(k, v)
105
+ }
106
+ }
107
+
108
+ xhr.send(args.body)
109
+ })
110
+ }
111
+
112
+ /**
113
+ * Creates a browser-native XHR transport.
114
+ * We use XMLHttpRequest instead of fetch because fetch does not yet support
115
+ * upload progress events in a standard cross-browser way.
116
+ */
117
+ export function createXHRTransport(): UploadTransport & {
118
+ // Optional: for tus strategies (PATCH)
119
+ patch?: (args: XhrCommonArgs & { body: Blob | ArrayBuffer }) => Promise<{ headers: Record<string, string> }>
120
+ } {
121
+ return {
122
+ async postForm(args) {
123
+ // Note: presigned POST is usually absolute already (MinIO/S3 URL)
124
+ const form = new FormData()
125
+ for (const [k, v] of Object.entries(args.fields)) form.append(k, v)
126
+ form.append('file', args.file, args.filename ?? 'file')
127
+
128
+ const out = await xhrRequest({
129
+ method: 'POST',
130
+ url: args.url,
131
+ body: form,
132
+ signal: args.signal,
133
+ onProgress: args.onProgress,
134
+ })
135
+
136
+ return { headers: out.headers }
137
+ },
138
+
139
+ async put(args) {
140
+ const out = await xhrRequest({
141
+ method: 'PUT',
142
+ url: args.url,
143
+ body: args.body,
144
+ signal: args.signal,
145
+ headers: args.headers,
146
+ onProgress: args.onProgress,
147
+ })
148
+
149
+ // multipart needs ETag from each part
150
+ return { headers: out.headers, etag: out.etag }
151
+ },
152
+
153
+ // Optional for tus strategy (PATCH)
154
+ async patch(args) {
155
+ const out = await xhrRequest({
156
+ method: 'PATCH',
157
+ url: args.url,
158
+ body: args.body,
159
+ signal: args.signal,
160
+ headers: args.headers,
161
+ onProgress: args.onProgress,
162
+ })
163
+ return { headers: out.headers }
164
+ },
165
+ }
166
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Abstraction layer for network operations.
3
+ * Allows switching between XHR (browser) or other implementations (testing/Node).
4
+ *
5
+ * Designed to support:
6
+ * - Deterministic progress tracking (requires XHR in browsers)
7
+ * - Cancellation via AbortSignal
8
+ * - Header parsing (ETag extraction)
9
+ */
10
+ export interface UploadTransport {
11
+ /**
12
+ * Performs an HTTP PUT request (typically for S3 signed URLs).
13
+ */
14
+ put(args: {
15
+ url: string
16
+ body: Blob
17
+ headers?: Record<string, string>
18
+ signal: AbortSignal
19
+ onProgress?: (u: number, t: number) => void
20
+ }): Promise<{ etag?: string; headers?: Record<string, string> }>
21
+
22
+ /**
23
+ * Performs an HTTP POST multipart request.
24
+ */
25
+ postForm(args: {
26
+ url: string
27
+ fields: Record<string, string>
28
+ file: File | Blob
29
+ filename?: string
30
+ signal: AbortSignal
31
+ onProgress?: (uploadedBytes: number, totalBytes: number) => void
32
+ }): Promise<{ etag?: string; headers?: Record<string, string> }>
33
+
34
+ /**
35
+ * Performs an HTTP request.
36
+ */
37
+ patch(args: {
38
+ url: string
39
+ body: Blob | ArrayBuffer
40
+ headers?: Record<string, string>
41
+ signal: AbortSignal
42
+ onProgress?: (u: number, t: number) => void
43
+ }): Promise<{ headers?: Record<string, string> }>
44
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Public commands accepted by the upload store.
3
+ *
4
+ * Reducer/effects should treat commands as "user intent".
5
+ */
6
+ export type UploadCommand<P extends string> =
7
+ | { type: 'addFiles'; files: File[]; purpose: P }
8
+ | { type: 'start'; localId: string }
9
+ | { type: 'startAll'; purpose?: P }
10
+ | { type: 'pause'; localId: string }
11
+ | { type: 'pauseAll'; purpose?: P }
12
+ | { type: 'resume'; localId: string }
13
+ | { type: 'cancel'; localId: string }
14
+ | { type: 'cancelAll'; purpose?: P }
15
+ | { type: 'retry'; localId: string }
16
+ | { type: 'rebind'; localId: string; file: File }
17
+ | { type: 'remove'; localId: string }
@@ -0,0 +1,45 @@
1
+ import type {
2
+ AnyCursor,
3
+ CursorMap,
4
+ FileFingerprint,
5
+ IntentMap,
6
+ RejectReason,
7
+ UploadError,
8
+ UploadResultBase,
9
+ } from '../contracts'
10
+ import type { UploadCompletionKind } from './outcome.types'
11
+
12
+ /**
13
+ * Public event map emitted to consumers.
14
+ *
15
+ * Consumers subscribe via {@link TypedEmitter.on}.
16
+ */
17
+ export type UploadEventMap<
18
+ M extends IntentMap,
19
+ C extends CursorMap<M>,
20
+ P extends string,
21
+ R extends UploadResultBase = UploadResultBase,
22
+ > = {
23
+ 'file.added': { localId: string; purpose: P; file: File; fingerprint: FileFingerprint }
24
+ 'file.rejected': { file: File; reason: RejectReason }
25
+
26
+ 'validation.ok': { localId: string }
27
+ 'validation.failed': { localId: string; reason: RejectReason }
28
+
29
+ 'intent.creating': { localId: string }
30
+ 'intent.created': { localId: string; intent: M[keyof M] }
31
+ 'intent.failed': { localId: string; error: UploadError; retryable: boolean }
32
+
33
+ 'upload.queued': { localId: string }
34
+ 'upload.resumed': { localId: string }
35
+ 'upload.started': { localId: string }
36
+ 'upload.progress': { localId: string; pct: number; uploadedBytes: number; totalBytes: number }
37
+ 'upload.cursor': { localId: string; cursor: AnyCursor<C> }
38
+ 'upload.paused': { localId: string; cursor: AnyCursor<C> }
39
+ 'upload.canceled': { localId: string }
40
+
41
+ 'upload.completing': { localId: string }
42
+ 'upload.completed': { localId: string; result: R; completedBy: UploadCompletionKind }
43
+
44
+ 'upload.error': { localId: string; error: UploadError; retryable: boolean }
45
+ }
@@ -0,0 +1,8 @@
1
+ export * from './commands.types'
2
+ export * from './event-map.types'
3
+ export * from './internal-events.types'
4
+ export * from './outcome.types'
5
+ export * from './progress.types'
6
+ export * from './reducer'
7
+ export * from './store'
8
+ export * from './validation'
@@ -0,0 +1,222 @@
1
+ import type {
2
+ AnyCursor,
3
+ AnyIntent,
4
+ CursorMap,
5
+ FileFingerprint,
6
+ IntentMap,
7
+ RejectReason,
8
+ UploadError,
9
+ UploadResultBase,
10
+ } from '../contracts'
11
+ import type { UploadCompletionKind } from './outcome.types'
12
+ import type { UploadProgress } from './progress.types'
13
+
14
+ /**
15
+ * Internal events emitted by effects and consumed by the reducer.
16
+ *
17
+ * These are not the same as public events. They are designed for correctness and state transitions.
18
+ *
19
+ * @typeParam M - Intent map
20
+ * @typeParam C - Cursor map
21
+ */
22
+ export type InternalEvent<
23
+ M extends IntentMap,
24
+ C extends CursorMap<M>,
25
+ P extends string,
26
+ R extends UploadResultBase = UploadResultBase,
27
+ > =
28
+ | {
29
+ type: 'files.added'
30
+ items: Array<{ localId: string; purpose: P; file: File; fingerprint: FileFingerprint; createdAt: number }>
31
+ }
32
+ | { type: 'fingerprint.updated'; localId: string; fingerprint: FileFingerprint }
33
+ | { type: 'validation.ok'; localId: string }
34
+ | { type: 'validation.failed'; localId: string; reason: RejectReason }
35
+ | { type: 'intent.ok'; localId: string; intent: AnyIntent<M> }
36
+ | { type: 'intent.failed'; localId: string; error: UploadError; retryable: boolean }
37
+ | { type: 'upload.begin'; localId: string; startedAt: number }
38
+ | { type: 'upload.progress'; localId: string; uploadedBytes: number; totalBytes: number }
39
+ | { type: 'cursor.updated'; localId: string; cursor: AnyCursor<C> }
40
+ | { type: 'upload.ok'; localId: string }
41
+ | { type: 'upload.failed'; localId: string; error: UploadError; retryable: boolean }
42
+ | { type: 'dedupe.ok'; localId: string; result: R }
43
+ | { type: 'complete.ok'; localId: string; result: R }
44
+ | { type: 'complete.failed'; localId: string; error: UploadError; retryable: boolean }
45
+ /** Internal completion for pause requests. */
46
+ | { type: 'paused'; localId: string; cursor: AnyCursor<C>; pausedAt: number }
47
+ /** Internal completion for cancel requests. */
48
+ | { type: 'canceled'; localId: string; canceledAt: number }
49
+
50
+ // ============================================================================
51
+ // UPLOAD ITEM (STATE MACHINE)
52
+ // ============================================================================
53
+
54
+ /**
55
+ * All possible upload phases.
56
+ *
57
+ * Useful for constraints and UI filtering.
58
+ */
59
+ export type UploadPhase =
60
+ | 'validating'
61
+ | 'creating_intent'
62
+ | 'ready'
63
+ | 'queued'
64
+ | 'uploading'
65
+ | 'paused'
66
+ | 'completing'
67
+ | 'completed'
68
+ | 'error'
69
+ | 'canceled'
70
+
71
+ /**
72
+ * State of a single upload item.
73
+ *
74
+ * This is a discriminated union on `phase`.
75
+ *
76
+ * @typeParam M - Intent map
77
+ * @typeParam C - Cursor map
78
+ * @typeParam P - Purpose union
79
+ */
80
+ export type UploadItem<
81
+ M extends IntentMap,
82
+ C extends CursorMap<M>,
83
+ P extends string,
84
+ R extends UploadResultBase = UploadResultBase,
85
+ > =
86
+ | {
87
+ /** Validating file against rules. */
88
+ phase: 'validating'
89
+ localId: string
90
+ purpose: P
91
+ fingerprint: FileFingerprint
92
+ file: File
93
+ createdAt: number
94
+ }
95
+ | {
96
+ /** Requesting upload intent from backend. */
97
+ phase: 'creating_intent'
98
+ localId: string
99
+ purpose: P
100
+ fingerprint: FileFingerprint
101
+ file: File
102
+ /** Current attempt number for this phase. */
103
+ attempt: number
104
+ createdAt: number
105
+ }
106
+ | {
107
+ /** Intent received, ready to start upload. */
108
+ phase: 'ready'
109
+ localId: string
110
+ purpose: P
111
+ fingerprint: FileFingerprint
112
+ file: File
113
+ intent: AnyIntent<M>
114
+ createdAt: number
115
+ cursor?: AnyCursor<C>
116
+ progress?: UploadProgress
117
+ attempt?: number
118
+ }
119
+ | {
120
+ /** Queued for upload (waiting for slot). */
121
+ phase: 'queued'
122
+ localId: string
123
+ purpose: P
124
+ fingerprint: FileFingerprint
125
+ file: File
126
+ intent: AnyIntent<M>
127
+ /** Timestamp when this item requested a concurrency slot. */
128
+ requestedAt: number
129
+ createdAt: number
130
+ cursor?: AnyCursor<C>
131
+ progress?: UploadProgress
132
+ attempt?: number
133
+ }
134
+ | {
135
+ /** Actively uploading bytes. */
136
+ phase: 'uploading'
137
+ localId: string
138
+ purpose: P
139
+ fingerprint: FileFingerprint
140
+ file: File
141
+ intent: AnyIntent<M>
142
+ startedAt: number
143
+ progress: UploadProgress
144
+ createdAt: number
145
+ cursor?: AnyCursor<C>
146
+ attempt?: number
147
+ }
148
+ | {
149
+ /** Upload paused (resumable). */
150
+ phase: 'paused'
151
+ localId: string
152
+ purpose: P
153
+ fingerprint: FileFingerprint
154
+ intent: AnyIntent<M>
155
+ cursor: AnyCursor<C>
156
+ progress: UploadProgress
157
+ pausedAt: number
158
+ createdAt: number
159
+ /**
160
+ * File is optional because a paused item can be restored from persistence without a File.
161
+ * Use `rebind` to attach the File again before resuming (when required).
162
+ */
163
+ file?: File
164
+ attempt?: number
165
+ }
166
+ | {
167
+ /** Bytes sent, finalizing with backend. */
168
+ phase: 'completing'
169
+ localId: string
170
+ purpose: P
171
+ fingerprint: FileFingerprint
172
+ file: File
173
+ intent: AnyIntent<M>
174
+ progress: UploadProgress
175
+ completingAt: number
176
+ createdAt: number
177
+ attempt?: number
178
+ }
179
+ | {
180
+ /** Upload completed successfully. */
181
+ phase: 'completed'
182
+ localId: string
183
+ file: File
184
+ purpose: P
185
+ fingerprint: FileFingerprint
186
+ intent?: AnyIntent<M>
187
+ completedBy: UploadCompletionKind
188
+ result: R
189
+ completedAt: number
190
+ createdAt: number
191
+ attempt?: number
192
+ }
193
+ | {
194
+ /** Upload failed (retryable or final). */
195
+ phase: 'error'
196
+ localId: string
197
+ purpose: P
198
+ fingerprint: FileFingerprint
199
+ error: UploadError
200
+ retryable: boolean
201
+ attempt: number
202
+ failedAt: number
203
+ createdAt: number
204
+ file?: File
205
+ intent?: AnyIntent<M>
206
+ cursor?: AnyCursor<C>
207
+ progress?: UploadProgress
208
+ }
209
+ | {
210
+ /** Upload canceled by user/system. */
211
+ phase: 'canceled'
212
+ localId: string
213
+ purpose: P
214
+ fingerprint: FileFingerprint
215
+ canceledAt: number
216
+ createdAt: number
217
+ file?: File
218
+ intent?: AnyIntent<M>
219
+ cursor?: AnyCursor<C>
220
+ progress?: UploadProgress
221
+ attempt?: number
222
+ }
@@ -0,0 +1,9 @@
1
+ import type { UploadError, UploadResultBase } from '../contracts'
2
+
3
+ export type UploadCompletionKind = 'upload' | 'dedupe'
4
+
5
+ export type UploadOutcome<R extends UploadResultBase> =
6
+ | { localId: string; status: 'completed'; completedBy: UploadCompletionKind; result: R }
7
+ | { localId: string; status: 'error'; error: UploadError }
8
+ | { localId: string; status: 'canceled' }
9
+ | { localId: string; status: 'missing' }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Upload progress snapshot.
3
+ *
4
+ * Invariants:
5
+ * - `totalBytes` should be `file.size` when known.
6
+ * - `pct` should be clamped to 0..100.
7
+ */
8
+ export type UploadProgress = {
9
+ /** Bytes uploaded so far. */
10
+ uploadedBytes: number
11
+ /** Total bytes to upload. */
12
+ totalBytes: number
13
+ /** Percent complete (0..100). */
14
+ pct: number
15
+ }