@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.
- package/dist/index.d.ts +1417 -0
- package/dist/index.js +6 -0
- package/package.json +43 -0
- package/src/core/client/config.types.ts +77 -0
- package/src/core/client/index.ts +4 -0
- package/src/core/client/plugins.types.ts +46 -0
- package/src/core/client/retry.types.ts +6 -0
- package/src/core/client/uploadClient.ts +19 -0
- package/src/core/contracts/backendApi.ts +62 -0
- package/src/core/contracts/cursor.types.ts +23 -0
- package/src/core/contracts/errors.types.ts +67 -0
- package/src/core/contracts/fingerprint.types.ts +18 -0
- package/src/core/contracts/index.ts +8 -0
- package/src/core/contracts/intent.types.ts +39 -0
- package/src/core/contracts/result.types.ts +11 -0
- package/src/core/contracts/strategy.types.ts +45 -0
- package/src/core/contracts/transport/index.ts +2 -0
- package/src/core/contracts/transport/transport.libs.ts +100 -0
- package/src/core/contracts/transport/transport.ts +166 -0
- package/src/core/contracts/transport/transport.types.ts +44 -0
- package/src/core/engine/commands.types.ts +17 -0
- package/src/core/engine/event-map.types.ts +45 -0
- package/src/core/engine/index.ts +8 -0
- package/src/core/engine/internal-events.types.ts +222 -0
- package/src/core/engine/outcome.types.ts +9 -0
- package/src/core/engine/progress.types.ts +15 -0
- package/src/core/engine/reducer.ts +435 -0
- package/src/core/engine/store/handlers/add-file.ts +103 -0
- package/src/core/engine/store/handlers/cancel.ts +56 -0
- package/src/core/engine/store/handlers/clean-up.ts +66 -0
- package/src/core/engine/store/handlers/create-intent.ts +76 -0
- package/src/core/engine/store/handlers/finalize.ts +59 -0
- package/src/core/engine/store/handlers/index.ts +7 -0
- package/src/core/engine/store/handlers/pause.ts +16 -0
- package/src/core/engine/store/handlers/run-upload.ts +192 -0
- package/src/core/engine/store/index.ts +123 -0
- package/src/core/engine/store/store.dispatch.ts +78 -0
- package/src/core/engine/store/store.libs.ts +169 -0
- package/src/core/engine/store/store.runtime.ts +336 -0
- package/src/core/engine/store/store.schedule.ts +116 -0
- package/src/core/engine/store/store.types.ts +250 -0
- package/src/core/engine/validation/file.ts +77 -0
- package/src/core/engine/validation/index.ts +3 -0
- package/src/core/engine/validation/intent.ts +55 -0
- package/src/core/engine/validation/list.ts +50 -0
- package/src/core/index.ts +5 -0
- package/src/core/persistence/adapters.indexeddb.ts +92 -0
- package/src/core/persistence/adapters.local.ts +22 -0
- package/src/core/persistence/adapters.memory.ts +15 -0
- package/src/core/persistence/index.ts +5 -0
- package/src/core/persistence/persistence.ts +194 -0
- package/src/core/persistence/persistence.types.ts +66 -0
- package/src/core/utils/async.ts +3 -0
- package/src/core/utils/constants.ts +32 -0
- package/src/core/utils/emitter/emitter.types.ts +23 -0
- package/src/core/utils/emitter/index.ts +68 -0
- package/src/core/utils/fingerprint.ts +20 -0
- package/src/core/utils/guards.ts +3 -0
- package/src/core/utils/id.ts +3 -0
- package/src/core/utils/index.ts +6 -0
- package/src/index.ts +3 -0
- package/src/react/index.ts +2 -0
- package/src/react/upload-provider.tsx +96 -0
- package/src/react/use-uploader.tsx +115 -0
- package/src/strategies/index.ts +15 -0
- package/src/strategies/multipart/index.ts +243 -0
- package/src/strategies/post/index.ts +93 -0
- 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,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
|
+
}
|