@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,435 @@
|
|
|
1
|
+
import type { CursorMap, IntentMap, UploadResultBase } from '../contracts'
|
|
2
|
+
import { computeFingerprint, fingerprintMatches } from '../utils/fingerprint'
|
|
3
|
+
import type { UploadCommand } from './commands.types'
|
|
4
|
+
import type { InternalEvent, UploadItem } from './internal-events.types'
|
|
5
|
+
import type { UploadProgress } from './progress.types'
|
|
6
|
+
|
|
7
|
+
export type UploadState<
|
|
8
|
+
M extends IntentMap,
|
|
9
|
+
C extends CursorMap<M>,
|
|
10
|
+
P extends string,
|
|
11
|
+
R extends UploadResultBase = UploadResultBase,
|
|
12
|
+
> = {
|
|
13
|
+
/** Map of uploads by localId for O(1) lookups */
|
|
14
|
+
items: Map<string, UploadItem<M, C, P, R>>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createReducer<
|
|
18
|
+
M extends IntentMap,
|
|
19
|
+
C extends CursorMap<M>,
|
|
20
|
+
P extends string,
|
|
21
|
+
R extends UploadResultBase = UploadResultBase,
|
|
22
|
+
>() {
|
|
23
|
+
return function reduce(
|
|
24
|
+
state: UploadState<M, C, P, R>,
|
|
25
|
+
event: UploadCommand<P> | InternalEvent<M, C, P, R>,
|
|
26
|
+
): UploadState<M, C, P, R> {
|
|
27
|
+
const items = new Map(state.items)
|
|
28
|
+
|
|
29
|
+
const set = (localId: string, next: UploadItem<M, C, P, R>) => items.set(localId, next)
|
|
30
|
+
|
|
31
|
+
const now = Date.now()
|
|
32
|
+
|
|
33
|
+
// Type guard: Commands don't have dots in their type, internal events do (e.g., 'intent.ok')
|
|
34
|
+
const isCommand = (e: UploadCommand<P> | InternalEvent<M, C, P, R>): e is UploadCommand<P> => {
|
|
35
|
+
return 'type' in e && typeof e.type === 'string' && !e.type.includes('.')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Command handling
|
|
39
|
+
if (isCommand(event)) {
|
|
40
|
+
switch (event.type) {
|
|
41
|
+
case 'addFiles': {
|
|
42
|
+
// handled in store (creates items + emits events)
|
|
43
|
+
break
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
case 'start': {
|
|
47
|
+
const item = items.get(event.localId)
|
|
48
|
+
if (!item) break
|
|
49
|
+
if (item.phase !== 'ready') break
|
|
50
|
+
|
|
51
|
+
set(event.localId, {
|
|
52
|
+
...item,
|
|
53
|
+
phase: 'queued',
|
|
54
|
+
requestedAt: now,
|
|
55
|
+
})
|
|
56
|
+
break
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
case 'resume': {
|
|
60
|
+
const item = items.get(event.localId)
|
|
61
|
+
if (!item) break
|
|
62
|
+
if (item.phase !== 'paused') break
|
|
63
|
+
if (!item.file) break
|
|
64
|
+
|
|
65
|
+
set(event.localId, {
|
|
66
|
+
...item,
|
|
67
|
+
file: item.file,
|
|
68
|
+
phase: 'queued',
|
|
69
|
+
requestedAt: now,
|
|
70
|
+
})
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
case 'pause': {
|
|
75
|
+
const item = items.get(event.localId)
|
|
76
|
+
if (!item) break
|
|
77
|
+
|
|
78
|
+
// If it's queued but not started, revert back to ready.
|
|
79
|
+
if (item.phase === 'queued') {
|
|
80
|
+
set(event.localId, {
|
|
81
|
+
...item,
|
|
82
|
+
phase: 'ready',
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// If it's uploading, store will abort and an internal 'paused' event will move it.
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
case 'cancel': {
|
|
91
|
+
const item = items.get(event.localId)
|
|
92
|
+
if (!item) break
|
|
93
|
+
if (item.phase === 'completed' || item.phase === 'canceled') break
|
|
94
|
+
|
|
95
|
+
set(event.localId, toCanceled(item, now))
|
|
96
|
+
break
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
case 'retry': {
|
|
100
|
+
const item = items.get(event.localId)
|
|
101
|
+
if (!item) break
|
|
102
|
+
if (item.phase !== 'error' || !item.retryable) break
|
|
103
|
+
if (!item.file) break
|
|
104
|
+
|
|
105
|
+
if (item.intent) {
|
|
106
|
+
// If we have progress and intent, we were in completing phase - retry completing
|
|
107
|
+
if (item.progress && item.progress.pct === 100) {
|
|
108
|
+
set(event.localId, {
|
|
109
|
+
...item,
|
|
110
|
+
progress: item.progress,
|
|
111
|
+
intent: item.intent,
|
|
112
|
+
file: item.file,
|
|
113
|
+
phase: 'completing',
|
|
114
|
+
completingAt: now,
|
|
115
|
+
})
|
|
116
|
+
} else {
|
|
117
|
+
// Otherwise, retry from ready phase
|
|
118
|
+
set(event.localId, {
|
|
119
|
+
...item,
|
|
120
|
+
file: item.file,
|
|
121
|
+
intent: item.intent,
|
|
122
|
+
phase: 'ready',
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
// For intent creation failures, increment attempt on retry
|
|
127
|
+
set(event.localId, {
|
|
128
|
+
...item,
|
|
129
|
+
phase: 'creating_intent',
|
|
130
|
+
file: item.file,
|
|
131
|
+
attempt: (item.attempt ?? 1) + 1,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
break
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
case 'rebind': {
|
|
138
|
+
const item = items.get(event.localId)
|
|
139
|
+
if (!item) break
|
|
140
|
+
|
|
141
|
+
if (item.phase === 'paused' && !item.file) {
|
|
142
|
+
const fp = computeFingerprint(event.file)
|
|
143
|
+
if (fingerprintMatches(fp, item.fingerprint)) {
|
|
144
|
+
set(event.localId, { ...item, file: event.file })
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
break
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case 'remove': {
|
|
151
|
+
items.delete(event.localId)
|
|
152
|
+
break
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
case 'startAll':
|
|
156
|
+
case 'pauseAll':
|
|
157
|
+
case 'cancelAll': {
|
|
158
|
+
// handled in store
|
|
159
|
+
break
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { items }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Internal event handling
|
|
167
|
+
switch (event.type) {
|
|
168
|
+
case 'files.added': {
|
|
169
|
+
const ev = event
|
|
170
|
+
for (const item of ev.items) {
|
|
171
|
+
if (!items.has(item.localId)) {
|
|
172
|
+
set(item.localId, {
|
|
173
|
+
phase: 'validating',
|
|
174
|
+
localId: item.localId,
|
|
175
|
+
purpose: item.purpose,
|
|
176
|
+
file: item.file,
|
|
177
|
+
fingerprint: item.fingerprint,
|
|
178
|
+
createdAt: item.createdAt,
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
break
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
case 'fingerprint.updated': {
|
|
186
|
+
const ev = event
|
|
187
|
+
const item = items.get(ev.localId)
|
|
188
|
+
if (!item) break
|
|
189
|
+
|
|
190
|
+
set(ev.localId, { ...item, fingerprint: ev.fingerprint })
|
|
191
|
+
break
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
case 'validation.ok': {
|
|
195
|
+
const item = items.get(event.localId)
|
|
196
|
+
if (!item || item.phase !== 'validating') break
|
|
197
|
+
|
|
198
|
+
set(event.localId, {
|
|
199
|
+
...item,
|
|
200
|
+
phase: 'creating_intent',
|
|
201
|
+
attempt: 1,
|
|
202
|
+
})
|
|
203
|
+
break
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
case 'validation.failed': {
|
|
207
|
+
const ev = event
|
|
208
|
+
const item = items.get(ev.localId)
|
|
209
|
+
if (!item || item.phase !== 'validating') break
|
|
210
|
+
|
|
211
|
+
set(ev.localId, {
|
|
212
|
+
...item,
|
|
213
|
+
phase: 'error',
|
|
214
|
+
error: { code: 'validation_failed', reason: ev.reason, message: String(ev.reason) },
|
|
215
|
+
retryable: false,
|
|
216
|
+
attempt: 1,
|
|
217
|
+
failedAt: now,
|
|
218
|
+
})
|
|
219
|
+
break
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case 'intent.ok': {
|
|
223
|
+
const ev = event
|
|
224
|
+
const item = items.get(ev.localId)
|
|
225
|
+
if (!item || item.phase !== 'creating_intent') break
|
|
226
|
+
|
|
227
|
+
set(ev.localId, {
|
|
228
|
+
...item,
|
|
229
|
+
phase: 'ready',
|
|
230
|
+
intent: ev.intent,
|
|
231
|
+
})
|
|
232
|
+
break
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
case 'intent.failed': {
|
|
236
|
+
const ev = event
|
|
237
|
+
const item = items.get(ev.localId)
|
|
238
|
+
if (!item || item.phase !== 'creating_intent') break
|
|
239
|
+
|
|
240
|
+
set(ev.localId, {
|
|
241
|
+
...item,
|
|
242
|
+
phase: 'error',
|
|
243
|
+
error: ev.error,
|
|
244
|
+
retryable: ev.retryable,
|
|
245
|
+
failedAt: now,
|
|
246
|
+
})
|
|
247
|
+
break
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
case 'upload.begin': {
|
|
251
|
+
const ev = event
|
|
252
|
+
const item = items.get(ev.localId)
|
|
253
|
+
if (!item || item.phase !== 'queued') break
|
|
254
|
+
|
|
255
|
+
const total = item.file.size
|
|
256
|
+
const carried: UploadProgress | undefined = item.progress
|
|
257
|
+
|
|
258
|
+
const progress: UploadProgress = carried
|
|
259
|
+
? { ...carried, totalBytes: total, pct: pct(carried.uploadedBytes, total) }
|
|
260
|
+
: { uploadedBytes: 0, totalBytes: total, pct: 0 }
|
|
261
|
+
|
|
262
|
+
set(ev.localId, {
|
|
263
|
+
...item,
|
|
264
|
+
phase: 'uploading',
|
|
265
|
+
progress,
|
|
266
|
+
startedAt: ev.startedAt,
|
|
267
|
+
})
|
|
268
|
+
break
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
case 'upload.progress': {
|
|
272
|
+
const ev = event
|
|
273
|
+
const item = items.get(ev.localId)
|
|
274
|
+
if (!item || item.phase !== 'uploading') break
|
|
275
|
+
|
|
276
|
+
const progress: UploadProgress = {
|
|
277
|
+
uploadedBytes: ev.uploadedBytes,
|
|
278
|
+
totalBytes: ev.totalBytes,
|
|
279
|
+
pct: pct(ev.uploadedBytes, ev.totalBytes),
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
set(ev.localId, { ...item, progress })
|
|
283
|
+
break
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
case 'cursor.updated': {
|
|
287
|
+
const ev = event
|
|
288
|
+
const item = items.get(ev.localId)
|
|
289
|
+
if (!item) break
|
|
290
|
+
|
|
291
|
+
if (item.phase === 'uploading') {
|
|
292
|
+
set(ev.localId, { ...item, cursor: ev.cursor })
|
|
293
|
+
} else if (item.phase === 'queued') {
|
|
294
|
+
set(ev.localId, { ...item, cursor: ev.cursor })
|
|
295
|
+
} else if (item.phase === 'paused') {
|
|
296
|
+
set(ev.localId, { ...item, cursor: ev.cursor })
|
|
297
|
+
}
|
|
298
|
+
break
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
case 'upload.ok': {
|
|
302
|
+
const ev = event
|
|
303
|
+
const item = items.get(ev.localId)
|
|
304
|
+
if (!item || item.phase !== 'uploading') break
|
|
305
|
+
|
|
306
|
+
set(ev.localId, {
|
|
307
|
+
...item,
|
|
308
|
+
phase: 'completing',
|
|
309
|
+
progress: { uploadedBytes: item.file.size, totalBytes: item.file.size, pct: 100 },
|
|
310
|
+
completingAt: now,
|
|
311
|
+
})
|
|
312
|
+
break
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
case 'upload.failed': {
|
|
316
|
+
const ev = event
|
|
317
|
+
const item = items.get(ev.localId)
|
|
318
|
+
if (!item || item.phase !== 'uploading') break
|
|
319
|
+
|
|
320
|
+
set(ev.localId, {
|
|
321
|
+
...item,
|
|
322
|
+
phase: 'error',
|
|
323
|
+
error: ev.error,
|
|
324
|
+
retryable: ev.retryable,
|
|
325
|
+
attempt: 1,
|
|
326
|
+
failedAt: now,
|
|
327
|
+
})
|
|
328
|
+
break
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
case 'paused': {
|
|
332
|
+
const ev = event
|
|
333
|
+
const item = items.get(ev.localId)
|
|
334
|
+
if (!item || item.phase !== 'uploading') break
|
|
335
|
+
|
|
336
|
+
set(ev.localId, {
|
|
337
|
+
...item,
|
|
338
|
+
phase: 'paused',
|
|
339
|
+
cursor: ev.cursor,
|
|
340
|
+
pausedAt: ev.pausedAt,
|
|
341
|
+
})
|
|
342
|
+
break
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
case 'canceled': {
|
|
346
|
+
const ev = event
|
|
347
|
+
const item = items.get(ev.localId)
|
|
348
|
+
if (!item) break
|
|
349
|
+
|
|
350
|
+
set(ev.localId, toCanceled(item, ev.canceledAt))
|
|
351
|
+
break
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
case 'complete.ok': {
|
|
355
|
+
const ev = event
|
|
356
|
+
const item = items.get(ev.localId)
|
|
357
|
+
if (!item || item.phase !== 'completing') break
|
|
358
|
+
|
|
359
|
+
set(ev.localId, {
|
|
360
|
+
...item,
|
|
361
|
+
phase: 'completed',
|
|
362
|
+
completedBy: 'upload',
|
|
363
|
+
result: ev.result,
|
|
364
|
+
completedAt: now,
|
|
365
|
+
})
|
|
366
|
+
break
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
case 'dedupe.ok': {
|
|
370
|
+
const ev = event
|
|
371
|
+
const item = items.get(ev.localId)
|
|
372
|
+
if (!item || item.phase !== 'validating') break
|
|
373
|
+
|
|
374
|
+
set(ev.localId, {
|
|
375
|
+
phase: 'completed',
|
|
376
|
+
localId: item.localId,
|
|
377
|
+
file: item.file,
|
|
378
|
+
purpose: item.purpose,
|
|
379
|
+
fingerprint: item.fingerprint,
|
|
380
|
+
result: ev.result,
|
|
381
|
+
completedBy: 'dedupe',
|
|
382
|
+
completedAt: now,
|
|
383
|
+
createdAt: item.createdAt,
|
|
384
|
+
})
|
|
385
|
+
break
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
case 'complete.failed': {
|
|
389
|
+
const ev = event
|
|
390
|
+
const item = items.get(ev.localId)
|
|
391
|
+
if (!item || item.phase !== 'completing') break
|
|
392
|
+
|
|
393
|
+
// Increment attempt number for retry tracking
|
|
394
|
+
const nextAttempt = (item.attempt ?? 1) + 1
|
|
395
|
+
|
|
396
|
+
set(ev.localId, {
|
|
397
|
+
...item,
|
|
398
|
+
phase: 'error',
|
|
399
|
+
error: ev.error,
|
|
400
|
+
retryable: ev.retryable,
|
|
401
|
+
attempt: nextAttempt,
|
|
402
|
+
failedAt: now,
|
|
403
|
+
})
|
|
404
|
+
break
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return { items }
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Helpers
|
|
413
|
+
function toCanceled<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
|
|
414
|
+
item: UploadItem<M, C, P, R>,
|
|
415
|
+
canceledAt: number,
|
|
416
|
+
): UploadItem<M, C, P, R> {
|
|
417
|
+
return {
|
|
418
|
+
phase: 'canceled',
|
|
419
|
+
localId: item.localId,
|
|
420
|
+
purpose: item.purpose,
|
|
421
|
+
fingerprint: item.fingerprint,
|
|
422
|
+
canceledAt,
|
|
423
|
+
createdAt: item.createdAt,
|
|
424
|
+
file: 'file' in item ? item.file : undefined,
|
|
425
|
+
intent: 'intent' in item ? item.intent : undefined,
|
|
426
|
+
cursor: 'cursor' in item ? item.cursor : undefined,
|
|
427
|
+
progress: 'progress' in item ? item.progress : undefined,
|
|
428
|
+
attempt: 'attempt' in item ? item.attempt : undefined,
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function pct(uploaded: number, total: number) {
|
|
433
|
+
if (total <= 0) return 0
|
|
434
|
+
return Math.min(100, (uploaded / total) * 100)
|
|
435
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { CursorMap, FileFingerprint, IntentMap, UploadResultBase } from '../../../contracts'
|
|
2
|
+
import { generateId } from '../../../utils/id'
|
|
3
|
+
import { validateFile, validateFileList } from '../../validation'
|
|
4
|
+
import { calculateFileChecksum, computeFingerprint } from '../store.libs'
|
|
5
|
+
import type { StoreRuntime } from '../store.types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Handles the `addFiles` command.
|
|
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
|
|
21
|
+
*/
|
|
22
|
+
export function handleAddFiles<
|
|
23
|
+
M extends IntentMap,
|
|
24
|
+
C extends CursorMap<M>,
|
|
25
|
+
P extends string,
|
|
26
|
+
R extends UploadResultBase,
|
|
27
|
+
>(rt: StoreRuntime<M, C, P, R>, files: File[], purpose: P) {
|
|
28
|
+
const existingCount = Array.from(rt.state.items.values()).filter(
|
|
29
|
+
(item) => item.purpose === purpose && item.phase !== 'canceled',
|
|
30
|
+
).length
|
|
31
|
+
|
|
32
|
+
const { valid, rejected } = validateFileList(files, purpose, rt.opts.config, existingCount)
|
|
33
|
+
|
|
34
|
+
for (const { file, reason } of rejected) {
|
|
35
|
+
rt.emitter.emit('file.rejected', { reason, file })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const now = Date.now()
|
|
39
|
+
const toAdd: Array<{ localId: string; purpose: P; file: File; fingerprint: FileFingerprint; createdAt: number }> = []
|
|
40
|
+
|
|
41
|
+
for (const file of valid) {
|
|
42
|
+
const localId = generateId()
|
|
43
|
+
const fingerprint = (rt.opts.fingerprint ?? computeFingerprint)(file)
|
|
44
|
+
|
|
45
|
+
toAdd.push({ localId, purpose, file, fingerprint, createdAt: now })
|
|
46
|
+
|
|
47
|
+
rt.enqueueEffect(async () => {
|
|
48
|
+
// Calculate checksum for deduplication
|
|
49
|
+
let checksum: string | undefined
|
|
50
|
+
try {
|
|
51
|
+
checksum = await calculateFileChecksum(file)
|
|
52
|
+
// Update fingerprint with checksum
|
|
53
|
+
const updatedFingerprint = { ...fingerprint, checksum }
|
|
54
|
+
rt.applyInternal({ type: 'fingerprint.updated', localId, fingerprint: updatedFingerprint })
|
|
55
|
+
} catch (err) {
|
|
56
|
+
// If checksum calculation fails, continue without checksum
|
|
57
|
+
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
|
58
|
+
console.warn('[UploadEngine] Failed to calculate checksum:', err)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check for existing file by checksum if available
|
|
63
|
+
if (checksum && rt.opts.api.findByChecksum) {
|
|
64
|
+
try {
|
|
65
|
+
const existingFile = await rt.opts.api.findByChecksum({ checksum, purpose })
|
|
66
|
+
if (existingFile) {
|
|
67
|
+
// File already exists, skip upload and mark as completed
|
|
68
|
+
const currentItem = rt.state.items.get(localId)
|
|
69
|
+
if (currentItem && currentItem.phase === 'validating') {
|
|
70
|
+
rt.applyInternal({ type: 'dedupe.ok', localId, result: existingFile })
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
// If findByChecksum fails, continue with normal upload flow
|
|
76
|
+
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
|
77
|
+
console.warn('[UploadEngine] Failed to check for existing file:', err)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Allow extra sync validation hook
|
|
83
|
+
const reason = rt.opts.validateFile?.(file, purpose)
|
|
84
|
+
if (reason) {
|
|
85
|
+
rt.applyInternal({ type: 'validation.failed', localId, reason })
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Still run config validation per-file (covers case where caller skipped validateFileList)
|
|
90
|
+
const cfgReason = validateFile(file, purpose, rt.opts.config)
|
|
91
|
+
if (cfgReason) {
|
|
92
|
+
rt.applyInternal({ type: 'validation.failed', localId, reason: cfgReason })
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
rt.applyInternal({ type: 'validation.ok', localId })
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (toAdd.length > 0) {
|
|
101
|
+
rt.applyInternal({ type: 'files.added', items: toAdd })
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { CursorMap, IntentMap, UploadResultBase } from '../../../contracts'
|
|
2
|
+
import { hasIntent, isMultipartIntent } from '../store.libs'
|
|
3
|
+
import type { StoreRuntime } from '../store.types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Cancels a single upload item and aborts related inflight operations.
|
|
7
|
+
*
|
|
8
|
+
* This function is intentionally best-effort: abort calls can throw or be unsupported,
|
|
9
|
+
* and server-side multipart abort is attempted only when the adapter provides it.
|
|
10
|
+
*/
|
|
11
|
+
export function handleCancel<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
|
|
12
|
+
rt: StoreRuntime<M, C, P, R>,
|
|
13
|
+
localId: string,
|
|
14
|
+
) {
|
|
15
|
+
const item = rt.state.items.get(localId)
|
|
16
|
+
|
|
17
|
+
// Abort inflight intent
|
|
18
|
+
const intentCtl = rt.inflightIntents.get(localId)
|
|
19
|
+
if (intentCtl) {
|
|
20
|
+
intentCtl.abort({ reason: 'cancel' })
|
|
21
|
+
rt.inflightIntents.delete(localId)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Abort inflight complete
|
|
25
|
+
const completeCtl = rt.inflightCompletes.get(localId)
|
|
26
|
+
if (completeCtl) {
|
|
27
|
+
completeCtl.abort({ reason: 'cancel' })
|
|
28
|
+
rt.inflightCompletes.delete(localId)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Abort inflight upload
|
|
32
|
+
const inflight = rt.inflightUploads.get(localId)
|
|
33
|
+
if (inflight) {
|
|
34
|
+
inflight.mode = 'cancel'
|
|
35
|
+
inflight.controller.abort({ reason: 'cancel' })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Best-effort server abort for multipart uploads
|
|
39
|
+
if (item && hasIntent(item) && item.intent.strategy === 'multipart' && rt.opts.api.multipart?.abort) {
|
|
40
|
+
const intent = item.intent
|
|
41
|
+
if (isMultipartIntent(intent)) {
|
|
42
|
+
rt.enqueueEffect(async () => {
|
|
43
|
+
try {
|
|
44
|
+
const abort = rt.opts.api.multipart?.abort
|
|
45
|
+
if (abort) {
|
|
46
|
+
await abort({ fileId: intent.fileId, uploadId: intent.uploadId })
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// ignore
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// If not inflight, reducer will mark canceled.
|
|
56
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { CursorMap, IntentMap, UploadResultBase } from '../../../contracts'
|
|
2
|
+
import type { UploadState } from '../../reducer'
|
|
3
|
+
import type { StoreOptions } from '../store.types'
|
|
4
|
+
|
|
5
|
+
export function cleanupOldItems<
|
|
6
|
+
M extends IntentMap,
|
|
7
|
+
C extends CursorMap<M>,
|
|
8
|
+
P extends string,
|
|
9
|
+
R extends UploadResultBase,
|
|
10
|
+
>(opts: StoreOptions<M, C, P, R>, state: UploadState<M, C, P, R>): UploadState<M, C, P, R> | null {
|
|
11
|
+
const maxItems = opts.config.maxItems
|
|
12
|
+
const completedTTL = opts.config.completedItemTTL
|
|
13
|
+
const now = Date.now()
|
|
14
|
+
|
|
15
|
+
// Collect items to remove
|
|
16
|
+
const toRemove: string[] = []
|
|
17
|
+
const items = Array.from(state.items.entries())
|
|
18
|
+
|
|
19
|
+
// Remove items that exceed TTL
|
|
20
|
+
if (completedTTL !== undefined) {
|
|
21
|
+
for (const [localId, item] of items) {
|
|
22
|
+
if (
|
|
23
|
+
(item.phase === 'completed' || item.phase === 'canceled') &&
|
|
24
|
+
'completedAt' in item &&
|
|
25
|
+
typeof item.completedAt === 'number' &&
|
|
26
|
+
now - item.completedAt > completedTTL
|
|
27
|
+
) {
|
|
28
|
+
toRemove.push(localId)
|
|
29
|
+
} else if (
|
|
30
|
+
item.phase === 'canceled' &&
|
|
31
|
+
'canceledAt' in item &&
|
|
32
|
+
typeof item.canceledAt === 'number' &&
|
|
33
|
+
now - item.canceledAt > completedTTL
|
|
34
|
+
) {
|
|
35
|
+
toRemove.push(localId)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// If still over limit, remove oldest completed/canceled items
|
|
41
|
+
const remainingItems = items.filter(([id]) => !toRemove.includes(id))
|
|
42
|
+
if (remainingItems.length > maxItems) {
|
|
43
|
+
const completedOrCanceled = remainingItems
|
|
44
|
+
.filter(([, item]) => item.phase === 'completed' || item.phase === 'canceled')
|
|
45
|
+
.sort(([, a], [, b]) => {
|
|
46
|
+
const aTime = 'completedAt' in a ? a.completedAt : 'canceledAt' in a ? a.canceledAt : 0
|
|
47
|
+
const bTime = 'completedAt' in b ? b.completedAt : 'canceledAt' in b ? b.canceledAt : 0
|
|
48
|
+
return aTime - bTime
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const excess = remainingItems.length - maxItems
|
|
52
|
+
for (let i = 0; i < excess && i < completedOrCanceled.length; i++) {
|
|
53
|
+
toRemove.push(completedOrCanceled[i][0])
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Remove items
|
|
58
|
+
if (toRemove.length === 0) return null
|
|
59
|
+
|
|
60
|
+
const nextItems = new Map(state.items)
|
|
61
|
+
for (const localId of toRemove) {
|
|
62
|
+
nextItems.delete(localId)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { ...state, items: nextItems }
|
|
66
|
+
}
|