@gentleduck/upload 0.2.4 → 0.2.6
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/README.md +320 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/{core-BNxznRif.js → core-CMkrP9DX.js} +2 -2
- package/dist/{index-BXg9WIce.d.ts → index-C38Jmizu.d.ts} +51 -33
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +8 -47
- package/dist/react/index.js +1 -1
- package/dist/strategies/index.d.ts +5 -5
- package/package.json +1 -1
- package/src/__test__/effect-pool.test.ts +5 -0
- package/src/__test__/emitter.test.ts +85 -0
- package/src/__test__/fingerprint.test.ts +49 -0
- package/src/__test__/guards.test.ts +30 -0
- package/src/__test__/id.test.ts +14 -0
- package/src/__test__/persistence.test.ts +156 -0
- package/src/__test__/store-integration.test.ts +5 -0
- package/src/__test__/store-libs.test.ts +138 -0
- package/src/__test__/transport-libs.test.ts +42 -0
- package/src/__test__/transport.test.ts +182 -0
- package/src/core/client/client.ts +50 -0
- package/src/core/client/client.types.ts +184 -0
- package/src/core/contracts/contracts.types.ts +179 -0
- package/src/core/contracts/transport/transport.libs.ts +7 -32
- package/src/core/contracts/transport/transport.ts +5 -7
- package/src/core/engine/engine.types.ts +364 -0
- package/src/core/engine/internal-events.types.ts +3 -23
- package/src/core/engine/reducer.ts +5 -11
- package/src/core/engine/store/handlers/add-file.ts +6 -21
- package/src/core/engine/store/handlers/run-upload.ts +7 -7
- package/src/core/engine/store/handlers.ts +819 -0
- package/src/core/engine/store/store.libs.ts +17 -57
- package/src/core/engine/store/store.runtime.ts +10 -20
- package/src/core/engine/validation/file.ts +1 -1
- package/src/core/engine/validation.ts +216 -0
- package/src/core/utils/emitter/emitter.ts +143 -0
- package/src/react/upload-provider.tsx +7 -58
- package/src/react/uploader.types.ts +83 -0
- package/src/react/use-uploader.tsx +2 -5
- package/src/strategies/multipart/index.ts +9 -12
- package/src/strategies/post/index.ts +6 -46
|
@@ -110,17 +110,16 @@ function xhrRequest(args: {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* upload progress events in a standard cross-browser way.
|
|
113
|
+
* Browser-native XHR transport. Uses `XMLHttpRequest` over `fetch` because
|
|
114
|
+
* `fetch` lacks cross-browser upload-progress events.
|
|
116
115
|
*/
|
|
117
116
|
export function createXHRTransport(): UploadTransport & {
|
|
118
|
-
|
|
117
|
+
/** Optional PATCH for tus-style strategies. */
|
|
119
118
|
patch?: (args: XhrCommonArgs & { body: Blob | ArrayBuffer }) => Promise<{ headers: Record<string, string> }>
|
|
120
119
|
} {
|
|
121
120
|
return {
|
|
122
121
|
async postForm(args) {
|
|
123
|
-
//
|
|
122
|
+
// Presigned POST URLs are absolute (MinIO/S3).
|
|
124
123
|
const form = new FormData()
|
|
125
124
|
for (const [k, v] of Object.entries(args.fields)) form.append(k, v)
|
|
126
125
|
form.append('file', args.file, args.filename ?? 'file')
|
|
@@ -146,11 +145,10 @@ export function createXHRTransport(): UploadTransport & {
|
|
|
146
145
|
onProgress: args.onProgress,
|
|
147
146
|
})
|
|
148
147
|
|
|
149
|
-
// multipart needs ETag from each part
|
|
148
|
+
// S3 multipart needs the ETag from each part response.
|
|
150
149
|
return { headers: out.headers, etag: out.etag }
|
|
151
150
|
},
|
|
152
151
|
|
|
153
|
-
// Optional for tus strategy (PATCH)
|
|
154
152
|
async patch(args) {
|
|
155
153
|
const out = await xhrRequest({
|
|
156
154
|
method: 'PATCH',
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import type { Contracts } from '../contracts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Engine-level type surface.
|
|
5
|
+
*
|
|
6
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
7
|
+
*/
|
|
8
|
+
export namespace Engine {
|
|
9
|
+
// -------------------- Progress --------------------
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Upload progress snapshot with `pct` clamped to 0..100.
|
|
13
|
+
*
|
|
14
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
15
|
+
*/
|
|
16
|
+
export interface IUploadProgress {
|
|
17
|
+
/** Bytes uploaded so far. */
|
|
18
|
+
uploadedBytes: number
|
|
19
|
+
/** Total bytes to upload. */
|
|
20
|
+
totalBytes: number
|
|
21
|
+
/** Percent complete (0..100). */
|
|
22
|
+
pct: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// -------------------- Outcome --------------------
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* How a `completed` item finished.
|
|
29
|
+
*
|
|
30
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
31
|
+
*/
|
|
32
|
+
export type CompletionKind = 'upload' | 'dedupe'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Terminal outcome returned by `Store.IUploadStore.waitFor`.
|
|
36
|
+
*
|
|
37
|
+
* @template R Result type carried on successful completion.
|
|
38
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
39
|
+
*/
|
|
40
|
+
export type Outcome<R extends Contracts.IResultBase> =
|
|
41
|
+
| { localId: string; status: 'completed'; completedBy: CompletionKind; result: R }
|
|
42
|
+
| { localId: string; status: 'error'; error: Contracts.Error }
|
|
43
|
+
| { localId: string; status: 'canceled' }
|
|
44
|
+
| {
|
|
45
|
+
localId: string
|
|
46
|
+
status: 'missing'
|
|
47
|
+
/**
|
|
48
|
+
* Why the id is missing. Required so consumers exhaustively
|
|
49
|
+
* handle every cause via switch.
|
|
50
|
+
* - `'removed'`: explicit `dispatch({ type: 'remove' })`.
|
|
51
|
+
* - `'evicted'`: cleanup pass (`maxItems` / `completedItemTTL`).
|
|
52
|
+
* - `'never-existed'`: the id was never registered in this store.
|
|
53
|
+
* - `'destroyed'`: `store.destroy()` ran while the wait was pending.
|
|
54
|
+
*/
|
|
55
|
+
reason: 'removed' | 'evicted' | 'never-existed' | 'destroyed'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// -------------------- Commands --------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Public commands accepted by `store.dispatch`.
|
|
62
|
+
*
|
|
63
|
+
* @template P Purpose discriminator string.
|
|
64
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
65
|
+
*/
|
|
66
|
+
export type Command<P extends string> =
|
|
67
|
+
| { type: 'addFiles'; files: File[]; purpose: P }
|
|
68
|
+
| { type: 'start'; localId: string }
|
|
69
|
+
| { type: 'startAll'; purpose?: P }
|
|
70
|
+
| { type: 'pause'; localId: string }
|
|
71
|
+
| { type: 'pauseAll'; purpose?: P }
|
|
72
|
+
| { type: 'resume'; localId: string }
|
|
73
|
+
| { type: 'cancel'; localId: string }
|
|
74
|
+
| { type: 'cancelAll'; purpose?: P }
|
|
75
|
+
| { type: 'retry'; localId: string }
|
|
76
|
+
| { type: 'rebind'; localId: string; file: File }
|
|
77
|
+
| { type: 'remove'; localId: string }
|
|
78
|
+
|
|
79
|
+
// -------------------- Phases / Items --------------------
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Every upload phase; the `phase` discriminator of {@link Engine.Item}.
|
|
83
|
+
*
|
|
84
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
85
|
+
*/
|
|
86
|
+
export type Phase =
|
|
87
|
+
| 'validating'
|
|
88
|
+
| 'creating_intent'
|
|
89
|
+
| 'ready'
|
|
90
|
+
| 'queued'
|
|
91
|
+
| 'uploading'
|
|
92
|
+
| 'paused'
|
|
93
|
+
| 'completing'
|
|
94
|
+
| 'completed'
|
|
95
|
+
| 'error'
|
|
96
|
+
| 'canceled'
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Enum-style const map of every {@link Phase} value.
|
|
100
|
+
*
|
|
101
|
+
* Reading `Phases.completing` matches the string literal at compile time
|
|
102
|
+
* AND gives consumers a single import point instead of N magic-string
|
|
103
|
+
* checks. Use whichever style fits the call site.
|
|
104
|
+
*
|
|
105
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
106
|
+
*/
|
|
107
|
+
export const Phases = {
|
|
108
|
+
validating: 'validating',
|
|
109
|
+
creating_intent: 'creating_intent',
|
|
110
|
+
ready: 'ready',
|
|
111
|
+
queued: 'queued',
|
|
112
|
+
uploading: 'uploading',
|
|
113
|
+
paused: 'paused',
|
|
114
|
+
completing: 'completing',
|
|
115
|
+
completed: 'completed',
|
|
116
|
+
error: 'error',
|
|
117
|
+
canceled: 'canceled',
|
|
118
|
+
} as const satisfies Record<Phase, Phase>
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Discriminated union on `phase` describing a single upload's state.
|
|
122
|
+
*
|
|
123
|
+
* @template M Intent map keyed by strategy id.
|
|
124
|
+
* @template C Cursor map keyed by strategy id.
|
|
125
|
+
* @template P Purpose discriminator string.
|
|
126
|
+
* @template R Backend result payload.
|
|
127
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
128
|
+
*/
|
|
129
|
+
export type Item<
|
|
130
|
+
M extends Contracts.IntentMap,
|
|
131
|
+
C extends Contracts.CursorMap<M>,
|
|
132
|
+
P extends string,
|
|
133
|
+
R extends Contracts.IResultBase = Contracts.IResultBase,
|
|
134
|
+
> =
|
|
135
|
+
| {
|
|
136
|
+
phase: 'validating'
|
|
137
|
+
localId: string
|
|
138
|
+
purpose: P
|
|
139
|
+
fingerprint: Contracts.IFileFingerprint
|
|
140
|
+
file: File
|
|
141
|
+
createdAt: number
|
|
142
|
+
}
|
|
143
|
+
| {
|
|
144
|
+
phase: 'creating_intent'
|
|
145
|
+
localId: string
|
|
146
|
+
purpose: P
|
|
147
|
+
fingerprint: Contracts.IFileFingerprint
|
|
148
|
+
file: File
|
|
149
|
+
attempt: number
|
|
150
|
+
createdAt: number
|
|
151
|
+
}
|
|
152
|
+
| {
|
|
153
|
+
phase: 'ready'
|
|
154
|
+
localId: string
|
|
155
|
+
purpose: P
|
|
156
|
+
fingerprint: Contracts.IFileFingerprint
|
|
157
|
+
file: File
|
|
158
|
+
intent: Contracts.AnyIntent<M>
|
|
159
|
+
createdAt: number
|
|
160
|
+
cursor?: Contracts.AnyCursor<C>
|
|
161
|
+
progress?: IUploadProgress
|
|
162
|
+
attempt?: number
|
|
163
|
+
}
|
|
164
|
+
| {
|
|
165
|
+
phase: 'queued'
|
|
166
|
+
localId: string
|
|
167
|
+
purpose: P
|
|
168
|
+
fingerprint: Contracts.IFileFingerprint
|
|
169
|
+
file: File
|
|
170
|
+
intent: Contracts.AnyIntent<M>
|
|
171
|
+
requestedAt: number
|
|
172
|
+
createdAt: number
|
|
173
|
+
cursor?: Contracts.AnyCursor<C>
|
|
174
|
+
progress?: IUploadProgress
|
|
175
|
+
attempt?: number
|
|
176
|
+
}
|
|
177
|
+
| {
|
|
178
|
+
phase: 'uploading'
|
|
179
|
+
localId: string
|
|
180
|
+
purpose: P
|
|
181
|
+
fingerprint: Contracts.IFileFingerprint
|
|
182
|
+
file: File
|
|
183
|
+
intent: Contracts.AnyIntent<M>
|
|
184
|
+
startedAt: number
|
|
185
|
+
progress: IUploadProgress
|
|
186
|
+
createdAt: number
|
|
187
|
+
cursor?: Contracts.AnyCursor<C>
|
|
188
|
+
attempt?: number
|
|
189
|
+
}
|
|
190
|
+
| {
|
|
191
|
+
phase: 'paused'
|
|
192
|
+
localId: string
|
|
193
|
+
purpose: P
|
|
194
|
+
fingerprint: Contracts.IFileFingerprint
|
|
195
|
+
intent: Contracts.AnyIntent<M>
|
|
196
|
+
cursor: Contracts.AnyCursor<C>
|
|
197
|
+
progress: IUploadProgress
|
|
198
|
+
pausedAt: number
|
|
199
|
+
createdAt: number
|
|
200
|
+
file?: File
|
|
201
|
+
attempt?: number
|
|
202
|
+
}
|
|
203
|
+
| {
|
|
204
|
+
phase: 'completing'
|
|
205
|
+
localId: string
|
|
206
|
+
purpose: P
|
|
207
|
+
fingerprint: Contracts.IFileFingerprint
|
|
208
|
+
file: File
|
|
209
|
+
intent: Contracts.AnyIntent<M>
|
|
210
|
+
progress: IUploadProgress
|
|
211
|
+
completingAt: number
|
|
212
|
+
createdAt: number
|
|
213
|
+
attempt?: number
|
|
214
|
+
}
|
|
215
|
+
| {
|
|
216
|
+
phase: 'completed'
|
|
217
|
+
localId: string
|
|
218
|
+
file?: File
|
|
219
|
+
purpose: P
|
|
220
|
+
fingerprint: Contracts.IFileFingerprint
|
|
221
|
+
intent?: Contracts.AnyIntent<M>
|
|
222
|
+
completedBy: CompletionKind
|
|
223
|
+
result: R
|
|
224
|
+
completedAt: number
|
|
225
|
+
createdAt: number
|
|
226
|
+
attempt?: number
|
|
227
|
+
}
|
|
228
|
+
| {
|
|
229
|
+
phase: 'error'
|
|
230
|
+
localId: string
|
|
231
|
+
purpose: P
|
|
232
|
+
fingerprint: Contracts.IFileFingerprint
|
|
233
|
+
error: Contracts.Error
|
|
234
|
+
retryable: boolean
|
|
235
|
+
attempt: number
|
|
236
|
+
failedAt: number
|
|
237
|
+
createdAt: number
|
|
238
|
+
file?: File
|
|
239
|
+
intent?: Contracts.AnyIntent<M>
|
|
240
|
+
cursor?: Contracts.AnyCursor<C>
|
|
241
|
+
progress?: IUploadProgress
|
|
242
|
+
}
|
|
243
|
+
| {
|
|
244
|
+
phase: 'canceled'
|
|
245
|
+
localId: string
|
|
246
|
+
purpose: P
|
|
247
|
+
fingerprint: Contracts.IFileFingerprint
|
|
248
|
+
canceledAt: number
|
|
249
|
+
createdAt: number
|
|
250
|
+
file?: File
|
|
251
|
+
intent?: Contracts.AnyIntent<M>
|
|
252
|
+
cursor?: Contracts.AnyCursor<C>
|
|
253
|
+
progress?: IUploadProgress
|
|
254
|
+
attempt?: number
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// -------------------- Internal events --------------------
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Events emitted by effects and consumed by the reducer. Distinct from the
|
|
261
|
+
* public event surface -- these drive state transitions.
|
|
262
|
+
*
|
|
263
|
+
* @template M Intent map keyed by strategy id.
|
|
264
|
+
* @template C Cursor map keyed by strategy id.
|
|
265
|
+
* @template P Purpose discriminator string.
|
|
266
|
+
* @template R Backend result payload.
|
|
267
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
268
|
+
*/
|
|
269
|
+
export type InternalEvent<
|
|
270
|
+
M extends Contracts.IntentMap,
|
|
271
|
+
C extends Contracts.CursorMap<M>,
|
|
272
|
+
P extends string,
|
|
273
|
+
R extends Contracts.IResultBase = Contracts.IResultBase,
|
|
274
|
+
> =
|
|
275
|
+
| {
|
|
276
|
+
type: 'files.added'
|
|
277
|
+
items: Array<{
|
|
278
|
+
localId: string
|
|
279
|
+
purpose: P
|
|
280
|
+
file: File
|
|
281
|
+
fingerprint: Contracts.IFileFingerprint
|
|
282
|
+
createdAt: number
|
|
283
|
+
}>
|
|
284
|
+
}
|
|
285
|
+
| { type: 'fingerprint.updated'; localId: string; fingerprint: Contracts.IFileFingerprint }
|
|
286
|
+
| { type: 'validation.ok'; localId: string }
|
|
287
|
+
| { type: 'validation.failed'; localId: string; reason: Contracts.RejectReason }
|
|
288
|
+
| { type: 'intent.ok'; localId: string; intent: Contracts.AnyIntent<M> }
|
|
289
|
+
| { type: 'intent.failed'; localId: string; error: Contracts.Error; retryable: boolean }
|
|
290
|
+
| { type: 'upload.begin'; localId: string; startedAt: number }
|
|
291
|
+
| { type: 'upload.progress'; localId: string; uploadedBytes: number; totalBytes: number }
|
|
292
|
+
| { type: 'cursor.updated'; localId: string; cursor: Contracts.AnyCursor<C> }
|
|
293
|
+
| { type: 'upload.ok'; localId: string }
|
|
294
|
+
| { type: 'upload.failed'; localId: string; error: Contracts.Error; retryable: boolean }
|
|
295
|
+
| { type: 'dedupe.ok'; localId: string; result: R }
|
|
296
|
+
| { type: 'complete.ok'; localId: string; result: R }
|
|
297
|
+
| { type: 'complete.failed'; localId: string; error: Contracts.Error; retryable: boolean }
|
|
298
|
+
| { type: 'paused'; localId: string; cursor: Contracts.AnyCursor<C>; pausedAt: number }
|
|
299
|
+
| { type: 'canceled'; localId: string; canceledAt: number }
|
|
300
|
+
|
|
301
|
+
// -------------------- Public event map --------------------
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Public event map emitted to consumers via `store.on(type, cb)`.
|
|
305
|
+
*
|
|
306
|
+
* @template M Intent map keyed by strategy id.
|
|
307
|
+
* @template C Cursor map keyed by strategy id.
|
|
308
|
+
* @template P Purpose discriminator string.
|
|
309
|
+
* @template R Backend result payload.
|
|
310
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
311
|
+
*/
|
|
312
|
+
export type EventMap<
|
|
313
|
+
M extends Contracts.IntentMap,
|
|
314
|
+
C extends Contracts.CursorMap<M>,
|
|
315
|
+
P extends string,
|
|
316
|
+
R extends Contracts.IResultBase = Contracts.IResultBase,
|
|
317
|
+
> = {
|
|
318
|
+
'file.added': { localId: string; purpose: P; file: File; fingerprint: Contracts.IFileFingerprint }
|
|
319
|
+
'file.rejected': { file: File; reason: Contracts.RejectReason }
|
|
320
|
+
|
|
321
|
+
'validation.ok': { localId: string }
|
|
322
|
+
'validation.failed': { localId: string; reason: Contracts.RejectReason }
|
|
323
|
+
|
|
324
|
+
'intent.creating': { localId: string }
|
|
325
|
+
'intent.created': { localId: string; intent: M[keyof M] }
|
|
326
|
+
'intent.failed': { localId: string; error: Contracts.Error; retryable: boolean }
|
|
327
|
+
|
|
328
|
+
'upload.queued': { localId: string }
|
|
329
|
+
'upload.resumed': { localId: string }
|
|
330
|
+
'upload.started': { localId: string }
|
|
331
|
+
'upload.progress': { localId: string; pct: number; uploadedBytes: number; totalBytes: number }
|
|
332
|
+
'upload.cursor': { localId: string; cursor: Contracts.AnyCursor<C> }
|
|
333
|
+
'upload.paused': { localId: string; cursor: Contracts.AnyCursor<C> }
|
|
334
|
+
'upload.canceled': { localId: string }
|
|
335
|
+
|
|
336
|
+
'upload.completing': { localId: string }
|
|
337
|
+
'upload.completed': { localId: string; result: R; completedBy: CompletionKind }
|
|
338
|
+
|
|
339
|
+
'upload.error': { localId: string; error: Contracts.Error; retryable: boolean }
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Emitted whenever an item leaves state. `reason: 'user'` for an
|
|
343
|
+
* explicit `dispatch({ type: 'remove' })`; `reason: 'cleanup'` for
|
|
344
|
+
* an automatic eviction by `completedItemTTL` / `maxItems`. Lets
|
|
345
|
+
* consumers (waitFor, UI listeners, telemetry) react without polling
|
|
346
|
+
* the snapshot.
|
|
347
|
+
*/
|
|
348
|
+
'upload.removed': { localId: string; reason: 'user' | 'cleanup' }
|
|
349
|
+
|
|
350
|
+
'rebind.ok': { localId: string }
|
|
351
|
+
'rebind.failed': { localId: string; reason: RebindReason }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Why a `rebind` command did not bind a file.
|
|
356
|
+
*
|
|
357
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
358
|
+
*/
|
|
359
|
+
export type RebindReason =
|
|
360
|
+
| { code: 'no_item' }
|
|
361
|
+
| { code: 'wrong_phase'; phase: string }
|
|
362
|
+
| { code: 'already_bound' }
|
|
363
|
+
| { code: 'fingerprint_mismatch'; expected: Contracts.IFileFingerprint; got: Contracts.IFileFingerprint }
|
|
364
|
+
}
|
|
@@ -13,11 +13,7 @@ import type { UploadProgress } from './progress.types'
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
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
|
|
16
|
+
* Distinct from the public event surface — these drive state transitions.
|
|
21
17
|
*/
|
|
22
18
|
export type InternalEvent<
|
|
23
19
|
M extends IntentMap,
|
|
@@ -47,15 +43,7 @@ export type InternalEvent<
|
|
|
47
43
|
/** Internal completion for cancel requests. */
|
|
48
44
|
| { type: 'canceled'; localId: string; canceledAt: number }
|
|
49
45
|
|
|
50
|
-
|
|
51
|
-
// UPLOAD ITEM (STATE MACHINE)
|
|
52
|
-
// ============================================================================
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* All possible upload phases.
|
|
56
|
-
*
|
|
57
|
-
* Useful for constraints and UI filtering.
|
|
58
|
-
*/
|
|
46
|
+
/** Every upload phase; the `phase` discriminator of {@link UploadItem}. */
|
|
59
47
|
export type UploadPhase =
|
|
60
48
|
| 'validating'
|
|
61
49
|
| 'creating_intent'
|
|
@@ -68,15 +56,7 @@ export type UploadPhase =
|
|
|
68
56
|
| 'error'
|
|
69
57
|
| 'canceled'
|
|
70
58
|
|
|
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
|
-
*/
|
|
59
|
+
/** Discriminated union on `phase` describing a single upload's state. */
|
|
80
60
|
export type UploadItem<
|
|
81
61
|
M extends IntentMap,
|
|
82
62
|
C extends CursorMap<M>,
|
|
@@ -30,12 +30,11 @@ export function createReducer<
|
|
|
30
30
|
|
|
31
31
|
const now = Date.now()
|
|
32
32
|
|
|
33
|
-
//
|
|
33
|
+
// Commands have no dots in their `type`; internal events do (e.g. 'intent.ok').
|
|
34
34
|
const isCommand = (e: UploadCommand<P> | InternalEvent<M, C, P, R>): e is UploadCommand<P> => {
|
|
35
35
|
return 'type' in e && typeof e.type === 'string' && !e.type.includes('.')
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
// Command handling
|
|
39
38
|
if (isCommand(event)) {
|
|
40
39
|
switch (event.type) {
|
|
41
40
|
case 'addFiles': {
|
|
@@ -75,15 +74,14 @@ export function createReducer<
|
|
|
75
74
|
const item = items.get(event.localId)
|
|
76
75
|
if (!item) break
|
|
77
76
|
|
|
78
|
-
//
|
|
77
|
+
// Queued-but-not-started: revert to ready. Uploading: store aborts and
|
|
78
|
+
// emits 'paused' internally to move the item.
|
|
79
79
|
if (item.phase === 'queued') {
|
|
80
80
|
set(event.localId, {
|
|
81
81
|
...item,
|
|
82
82
|
phase: 'ready',
|
|
83
83
|
})
|
|
84
84
|
}
|
|
85
|
-
|
|
86
|
-
// If it's uploading, store will abort and an internal 'paused' event will move it.
|
|
87
85
|
break
|
|
88
86
|
}
|
|
89
87
|
|
|
@@ -103,7 +101,7 @@ export function createReducer<
|
|
|
103
101
|
if (!item.file) break
|
|
104
102
|
|
|
105
103
|
if (item.intent) {
|
|
106
|
-
//
|
|
104
|
+
// Progress at 100% with intent means we failed in 'completing' — retry that phase.
|
|
107
105
|
if (item.progress && item.progress.pct === 100) {
|
|
108
106
|
set(event.localId, {
|
|
109
107
|
...item,
|
|
@@ -114,7 +112,6 @@ export function createReducer<
|
|
|
114
112
|
completingAt: now,
|
|
115
113
|
})
|
|
116
114
|
} else {
|
|
117
|
-
// Otherwise, retry from ready phase
|
|
118
115
|
set(event.localId, {
|
|
119
116
|
...item,
|
|
120
117
|
file: item.file,
|
|
@@ -123,7 +120,7 @@ export function createReducer<
|
|
|
123
120
|
})
|
|
124
121
|
}
|
|
125
122
|
} else {
|
|
126
|
-
//
|
|
123
|
+
// Intent-creation failure path; bump attempt counter.
|
|
127
124
|
set(event.localId, {
|
|
128
125
|
...item,
|
|
129
126
|
phase: 'creating_intent',
|
|
@@ -163,7 +160,6 @@ export function createReducer<
|
|
|
163
160
|
return { items }
|
|
164
161
|
}
|
|
165
162
|
|
|
166
|
-
// Internal event handling
|
|
167
163
|
switch (event.type) {
|
|
168
164
|
case 'files.added': {
|
|
169
165
|
const ev = event
|
|
@@ -390,7 +386,6 @@ export function createReducer<
|
|
|
390
386
|
const item = items.get(ev.localId)
|
|
391
387
|
if (!item || item.phase !== 'completing') break
|
|
392
388
|
|
|
393
|
-
// Increment attempt number for retry tracking
|
|
394
389
|
const nextAttempt = (item.attempt ?? 1) + 1
|
|
395
390
|
|
|
396
391
|
set(ev.localId, {
|
|
@@ -409,7 +404,6 @@ export function createReducer<
|
|
|
409
404
|
}
|
|
410
405
|
}
|
|
411
406
|
|
|
412
|
-
// Helpers
|
|
413
407
|
function toCanceled<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
|
|
414
408
|
item: UploadItem<M, C, P, R>,
|
|
415
409
|
canceledAt: number,
|
|
@@ -5,19 +5,8 @@ import { calculateFileChecksum, computeFingerprint } from '../store.libs'
|
|
|
5
5
|
import type { StoreRuntime } from '../store.types'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* This function performs fast synchronous work immediately (validation + state insertion)
|
|
11
|
-
* and then schedules additional async work through the runtime effect queue
|
|
12
|
-
* (checksum computation, de-dupe checks, and final validation transitions).
|
|
13
|
-
*
|
|
14
|
-
* @template M - Intent map type
|
|
15
|
-
* @template C - Cursor map type
|
|
16
|
-
* @template P - Purpose string union type
|
|
17
|
-
*
|
|
18
|
-
* @param rt - Store runtime
|
|
19
|
-
* @param files - Files selected by the user
|
|
20
|
-
* @param purpose - Upload purpose used for validation/routing
|
|
8
|
+
* Sync: validate + insert. Async (queued): checksum, dedupe lookup,
|
|
9
|
+
* and the final validation transition.
|
|
21
10
|
*/
|
|
22
11
|
export function handleAddFiles<
|
|
23
12
|
M extends IntentMap,
|
|
@@ -45,26 +34,23 @@ export function handleAddFiles<
|
|
|
45
34
|
toAdd.push({ localId, purpose, file, fingerprint, createdAt: now })
|
|
46
35
|
|
|
47
36
|
rt.enqueueEffect(async () => {
|
|
48
|
-
// Calculate checksum for deduplication
|
|
49
37
|
let checksum: string | undefined
|
|
50
38
|
try {
|
|
51
39
|
checksum = await calculateFileChecksum(file)
|
|
52
|
-
// Update fingerprint with checksum
|
|
53
40
|
const updatedFingerprint = { ...fingerprint, checksum }
|
|
54
41
|
rt.applyInternal({ type: 'fingerprint.updated', localId, fingerprint: updatedFingerprint })
|
|
55
42
|
} catch (err) {
|
|
56
|
-
//
|
|
43
|
+
// Checksum failure is non-fatal — continue without dedupe.
|
|
57
44
|
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
|
58
45
|
console.warn('[UploadEngine] Failed to calculate checksum:', err)
|
|
59
46
|
}
|
|
60
47
|
}
|
|
61
48
|
|
|
62
|
-
//
|
|
49
|
+
// Dedupe: skip upload entirely when the backend already has the file.
|
|
63
50
|
if (checksum && rt.opts.api.findByChecksum) {
|
|
64
51
|
try {
|
|
65
52
|
const existingFile = await rt.opts.api.findByChecksum({ checksum, purpose })
|
|
66
53
|
if (existingFile) {
|
|
67
|
-
// File already exists, skip upload and mark as completed
|
|
68
54
|
const currentItem = rt.state.items.get(localId)
|
|
69
55
|
if (currentItem && currentItem.phase === 'validating') {
|
|
70
56
|
rt.applyInternal({ type: 'dedupe.ok', localId, result: existingFile })
|
|
@@ -72,21 +58,20 @@ export function handleAddFiles<
|
|
|
72
58
|
}
|
|
73
59
|
}
|
|
74
60
|
} catch (err) {
|
|
75
|
-
//
|
|
61
|
+
// Dedupe failure is non-fatal — fall through to normal upload.
|
|
76
62
|
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
|
77
63
|
console.warn('[UploadEngine] Failed to check for existing file:', err)
|
|
78
64
|
}
|
|
79
65
|
}
|
|
80
66
|
}
|
|
81
67
|
|
|
82
|
-
// Allow extra sync validation hook
|
|
83
68
|
const reason = rt.opts.validateFile?.(file, purpose)
|
|
84
69
|
if (reason) {
|
|
85
70
|
rt.applyInternal({ type: 'validation.failed', localId, reason })
|
|
86
71
|
return
|
|
87
72
|
}
|
|
88
73
|
|
|
89
|
-
//
|
|
74
|
+
// Re-run config validation per-file in case the caller skipped validateFileList.
|
|
90
75
|
const cfgReason = validateFile(file, purpose, rt.opts.config)
|
|
91
76
|
if (cfgReason) {
|
|
92
77
|
rt.applyInternal({ type: 'validation.failed', localId, reason: cfgReason })
|
|
@@ -10,7 +10,7 @@ export async function runUpload<
|
|
|
10
10
|
>(rt: StoreRuntime<M, C, P, R>, localId: string) {
|
|
11
11
|
const item = rt.state.items.get(localId)
|
|
12
12
|
|
|
13
|
-
//
|
|
13
|
+
// State moved on (or never valid) — drop any stale inflight entry.
|
|
14
14
|
if (!item || item.phase !== 'uploading' || !hasIntent(item)) {
|
|
15
15
|
rt.inflightUploads.delete(localId)
|
|
16
16
|
return
|
|
@@ -39,7 +39,7 @@ export async function runUpload<
|
|
|
39
39
|
rt.applyInternal({ type: 'paused', localId, cursor, pausedAt: Date.now() })
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
//
|
|
42
|
+
// Paused/canceled before the request begins: short-circuit to the right terminal state.
|
|
43
43
|
if (controller.signal.aborted) {
|
|
44
44
|
rt.inflightUploads.delete(localId)
|
|
45
45
|
|
|
@@ -92,7 +92,7 @@ export async function runUpload<
|
|
|
92
92
|
return
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
// Throttle progress events
|
|
95
|
+
// Throttle progress events to limit re-renders and reducer churn.
|
|
96
96
|
let lastEmit = 0
|
|
97
97
|
const progressThrottle = rt.opts.config.progressThrottleMs
|
|
98
98
|
|
|
@@ -138,16 +138,16 @@ export async function runUpload<
|
|
|
138
138
|
return
|
|
139
139
|
}
|
|
140
140
|
if (controller.signal.aborted && inflight?.mode === 'pause') {
|
|
141
|
-
// Paused
|
|
141
|
+
// Paused after strategy returned: reducer requires a cursor — synthesize an
|
|
142
|
+
// empty one if the strategy never persisted progress.
|
|
142
143
|
const cur = rt.state.items.get(localId)
|
|
143
144
|
const cursor = (cur && hasCursor(cur) ? cur.cursor : undefined) || { strategy: strategyId, value: undefined }
|
|
144
145
|
applyPaused(cursor)
|
|
145
146
|
return
|
|
146
147
|
}
|
|
147
148
|
|
|
148
|
-
//
|
|
149
|
+
// 'completing' phase; finalizeUpload runs via scheduleWork().
|
|
149
150
|
rt.applyInternal({ type: 'upload.ok', localId })
|
|
150
|
-
// finalizeUpload scheduled by scheduleWork() via completing phase
|
|
151
151
|
} catch (err: unknown) {
|
|
152
152
|
rt.inflightUploads.delete(localId)
|
|
153
153
|
|
|
@@ -164,7 +164,7 @@ export async function runUpload<
|
|
|
164
164
|
return
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
//
|
|
167
|
+
// Abort with no mode set: treat as cancel.
|
|
168
168
|
applyCanceled()
|
|
169
169
|
return
|
|
170
170
|
}
|