@gentleduck/upload 0.2.5 → 0.3.0
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 +297 -0
- package/dist/core/index.d.ts +2 -3
- package/dist/core/index.js +1 -1
- package/dist/core-BVgloTiP.js +10 -0
- package/dist/index-DCqyBVcx.d.ts +1441 -0
- package/dist/index-DsZHPmJm.d.ts +225 -0
- package/dist/index-KROXtLQh.d.ts +281 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +2 -90
- package/dist/react/index.js +1 -1
- package/dist/react-euPrDv24.js +1 -0
- package/dist/strategies/index.d.ts +2 -56
- package/dist/strategies/index.js +1 -1
- package/dist/strategies-CUF0s68j.js +1 -0
- package/package.json +3 -2
- package/src/__tests__/emitter.test.ts +85 -0
- package/src/__tests__/fingerprint.test.ts +49 -0
- package/src/__tests__/guards.test.ts +40 -0
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/__tests__/persistence.test.ts +191 -0
- package/src/__tests__/sanitize-filename.test.ts +109 -0
- package/src/__tests__/sleep.test.ts +34 -0
- package/src/__tests__/static-analysis.test.ts +145 -0
- package/src/__tests__/store-libs.test.ts +192 -0
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/__tests__/transport-libs.test.ts +42 -0
- package/src/__tests__/transport.test.ts +182 -0
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +373 -0
- package/src/core/contracts/index.ts +3 -8
- package/src/core/contracts/transport/index.ts +3 -2
- package/src/core/contracts/transport/transport.fetch.ts +203 -0
- package/src/core/contracts/transport/transport.libs.ts +13 -39
- package/src/core/contracts/transport/transport.ts +96 -90
- package/src/core/contracts/transport/transport.types.ts +71 -35
- package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
- package/src/core/engine/engine.libs.ts +35 -0
- package/src/core/engine/engine.types.ts +490 -0
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +93 -53
- package/src/core/engine/store/handlers/add-file.ts +43 -48
- package/src/core/engine/store/handlers/cancel.ts +13 -8
- package/src/core/engine/store/handlers/clean-up.ts +11 -8
- package/src/core/engine/store/handlers/create-intent.ts +82 -27
- package/src/core/engine/store/handlers/finalize.ts +30 -13
- package/src/core/engine/store/handlers/index.ts +7 -7
- package/src/core/engine/store/handlers/pause.ts +8 -6
- package/src/core/engine/store/handlers/run-upload.ts +54 -39
- package/src/core/engine/store/index.ts +27 -21
- package/src/core/engine/store/store.dispatch.ts +10 -8
- package/src/core/engine/store/store.libs.ts +197 -94
- package/src/core/engine/store/store.runtime.ts +52 -53
- package/src/core/engine/store/store.schedule.ts +27 -21
- package/src/core/engine/store/store.types.ts +177 -227
- package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
- package/src/core/engine/validation/file.ts +8 -4
- package/src/core/engine/validation/index.ts +1 -0
- package/src/core/engine/validation/intent.ts +49 -27
- package/src/core/engine/validation/list.ts +14 -15
- package/src/core/engine/validation/mime.ts +39 -0
- package/src/core/errors.ts +271 -0
- package/src/core/index.ts +28 -5
- package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
- package/src/core/persistence/__tests__/persistence.test.ts +85 -0
- package/src/core/persistence/adapters.indexeddb.ts +9 -3
- package/src/core/persistence/adapters.local.ts +9 -3
- package/src/core/persistence/adapters.memory.ts +35 -13
- package/src/core/persistence/index.ts +5 -5
- package/src/core/persistence/persistence.ts +73 -60
- package/src/core/persistence/persistence.types.ts +140 -60
- package/src/core/utils/__tests__/guards.test.ts +93 -0
- package/src/core/utils/__tests__/url-safety.test.ts +112 -0
- package/src/core/utils/async.ts +7 -2
- package/src/core/utils/constants.ts +3 -9
- package/src/core/utils/emitter/emitter.ts +126 -0
- package/src/core/utils/emitter/emitter.types.ts +33 -16
- package/src/core/utils/emitter/index.ts +2 -74
- package/src/core/utils/fingerprint.ts +16 -5
- package/src/core/utils/guards.ts +91 -1
- package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
- package/src/core/utils/hash/index.ts +126 -0
- package/src/core/utils/hash/sha256.ts +179 -0
- package/src/core/utils/id.ts +33 -1
- package/src/core/utils/index.ts +15 -6
- package/src/core/utils/mime-sniff.ts +119 -0
- package/src/core/utils/sanitize-filename.ts +85 -0
- package/src/core/utils/url-safety.ts +156 -0
- package/src/react/__tests__/use-dropzone.test.ts +64 -0
- package/src/react/index.ts +5 -2
- package/src/react/upload-provider.tsx +59 -95
- package/src/react/uploader.types.ts +80 -0
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +97 -93
- package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
- package/src/strategies/_shared/retry.ts +79 -0
- package/src/strategies/index.ts +9 -15
- package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
- package/src/strategies/multipart/index.ts +211 -116
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +113 -71
- package/src/strategies/put/__tests__/put.test.ts +132 -0
- package/src/strategies/put/index.ts +139 -0
- package/src/strategies/registry/index.ts +12 -9
- package/src/strategies/tus/__tests__/tus.test.ts +159 -0
- package/src/strategies/tus/index.ts +211 -0
- package/dist/async-DxRWu4le.js +0 -1
- package/dist/core-BNxznRif.js +0 -2
- package/dist/index-BXg9WIce.d.ts +0 -987
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- package/src/core/client/config.types.ts +0 -77
- package/src/core/client/index.ts +0 -4
- package/src/core/client/plugins.types.ts +0 -46
- package/src/core/client/retry.types.ts +0 -6
- package/src/core/client/uploadClient.ts +0 -19
- package/src/core/contracts/backendApi.ts +0 -62
- package/src/core/contracts/cursor.types.ts +0 -23
- package/src/core/contracts/errors.types.ts +0 -67
- package/src/core/contracts/fingerprint.types.ts +0 -18
- package/src/core/contracts/intent.types.ts +0 -39
- package/src/core/contracts/result.types.ts +0 -11
- package/src/core/contracts/strategy.types.ts +0 -45
- package/src/core/engine/commands.types.ts +0 -17
- package/src/core/engine/event-map.types.ts +0 -45
- package/src/core/engine/internal-events.types.ts +0 -222
- package/src/core/engine/outcome.types.ts +0 -9
- package/src/core/engine/progress.types.ts +0 -15
|
@@ -1,41 +1,59 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Contracts } from '../contracts'
|
|
2
|
+
import { UploadValidationError } from '../errors'
|
|
2
3
|
import { computeFingerprint, fingerprintMatches } from '../utils/fingerprint'
|
|
3
|
-
import type {
|
|
4
|
-
import type { InternalEvent, UploadItem } from './internal-events.types'
|
|
5
|
-
import type { UploadProgress } from './progress.types'
|
|
4
|
+
import type { Engine } from './engine.types'
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
6
|
+
function stripError(err: { code: string; message: string; retryable?: boolean }): Contracts.StepError {
|
|
7
|
+
const result: Contracts.StepError = { code: err.code, message: err.message }
|
|
8
|
+
if (err.retryable !== undefined) result.retryable = err.retryable
|
|
9
|
+
return result
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function closeStep(steps: Contracts.UploadStep[], leftAt: number): Contracts.UploadStep[] {
|
|
13
|
+
if (!steps.length) return steps
|
|
14
|
+
const last = steps[steps.length - 1]
|
|
15
|
+
if (!last || last.leftAt !== undefined) return steps
|
|
16
|
+
const closed: Contracts.UploadStep = {
|
|
17
|
+
phase: last.phase,
|
|
18
|
+
enteredAt: last.enteredAt,
|
|
19
|
+
leftAt,
|
|
20
|
+
attempt: last.attempt,
|
|
21
|
+
...(last.error !== undefined ? { error: last.error } : {}),
|
|
22
|
+
}
|
|
23
|
+
return [...steps.slice(0, -1), closed]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function openStep(
|
|
27
|
+
steps: Contracts.UploadStep[],
|
|
28
|
+
phase: string,
|
|
29
|
+
attempt: number,
|
|
30
|
+
enteredAt: number,
|
|
31
|
+
error?: Contracts.StepError,
|
|
32
|
+
): Contracts.UploadStep[] {
|
|
33
|
+
return [...closeStep(steps, enteredAt), { phase, enteredAt, attempt, error }]
|
|
15
34
|
}
|
|
16
35
|
|
|
17
36
|
export function createReducer<
|
|
18
|
-
M extends
|
|
19
|
-
C extends
|
|
37
|
+
M extends Contracts.Intent.Map,
|
|
38
|
+
C extends Contracts.Cursor.Map<M>,
|
|
20
39
|
P extends string,
|
|
21
|
-
R extends
|
|
40
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
22
41
|
>() {
|
|
23
42
|
return function reduce(
|
|
24
|
-
state:
|
|
25
|
-
event:
|
|
26
|
-
):
|
|
43
|
+
state: Engine.State<M, C, P, R>,
|
|
44
|
+
event: Engine.Command<P> | Engine.Event<M, C, P, R>,
|
|
45
|
+
): Engine.State<M, C, P, R> {
|
|
27
46
|
const items = new Map(state.items)
|
|
28
47
|
|
|
29
|
-
const set = (localId: string, next:
|
|
48
|
+
const set = (localId: string, next: Engine.Item<M, C, P, R>) => items.set(localId, next)
|
|
30
49
|
|
|
31
50
|
const now = Date.now()
|
|
32
51
|
|
|
33
|
-
//
|
|
34
|
-
const isCommand = (e:
|
|
52
|
+
// Commands have no dots in their `type`; internal events do (e.g. 'intent.ok').
|
|
53
|
+
const isCommand = (e: Engine.Command<P> | Engine.Event<M, C, P, R>): e is Engine.Command<P> => {
|
|
35
54
|
return 'type' in e && typeof e.type === 'string' && !e.type.includes('.')
|
|
36
55
|
}
|
|
37
56
|
|
|
38
|
-
// Command handling
|
|
39
57
|
if (isCommand(event)) {
|
|
40
58
|
switch (event.type) {
|
|
41
59
|
case 'addFiles': {
|
|
@@ -52,6 +70,7 @@ export function createReducer<
|
|
|
52
70
|
...item,
|
|
53
71
|
phase: 'queued',
|
|
54
72
|
requestedAt: now,
|
|
73
|
+
steps: openStep(item.steps, 'queued', item.attempt ?? 1, now),
|
|
55
74
|
})
|
|
56
75
|
break
|
|
57
76
|
}
|
|
@@ -67,6 +86,7 @@ export function createReducer<
|
|
|
67
86
|
file: item.file,
|
|
68
87
|
phase: 'queued',
|
|
69
88
|
requestedAt: now,
|
|
89
|
+
steps: openStep(item.steps, 'queued', item.attempt ?? 1, now),
|
|
70
90
|
})
|
|
71
91
|
break
|
|
72
92
|
}
|
|
@@ -75,15 +95,15 @@ export function createReducer<
|
|
|
75
95
|
const item = items.get(event.localId)
|
|
76
96
|
if (!item) break
|
|
77
97
|
|
|
78
|
-
//
|
|
98
|
+
// Queued-but-not-started: revert to ready. Uploading: store aborts and
|
|
99
|
+
// emits 'paused' internally to move the item.
|
|
79
100
|
if (item.phase === 'queued') {
|
|
80
101
|
set(event.localId, {
|
|
81
102
|
...item,
|
|
82
103
|
phase: 'ready',
|
|
104
|
+
steps: openStep(item.steps, 'ready', item.attempt ?? 1, now),
|
|
83
105
|
})
|
|
84
106
|
}
|
|
85
|
-
|
|
86
|
-
// If it's uploading, store will abort and an internal 'paused' event will move it.
|
|
87
107
|
break
|
|
88
108
|
}
|
|
89
109
|
|
|
@@ -103,7 +123,7 @@ export function createReducer<
|
|
|
103
123
|
if (!item.file) break
|
|
104
124
|
|
|
105
125
|
if (item.intent) {
|
|
106
|
-
//
|
|
126
|
+
// Progress at 100% with intent means we failed in 'completing' retry that phase.
|
|
107
127
|
if (item.progress && item.progress.pct === 100) {
|
|
108
128
|
set(event.localId, {
|
|
109
129
|
...item,
|
|
@@ -112,23 +132,26 @@ export function createReducer<
|
|
|
112
132
|
file: item.file,
|
|
113
133
|
phase: 'completing',
|
|
114
134
|
completingAt: now,
|
|
135
|
+
steps: openStep(item.steps, 'completing', item.attempt ?? 1, now),
|
|
115
136
|
})
|
|
116
137
|
} else {
|
|
117
|
-
// Otherwise, retry from ready phase
|
|
118
138
|
set(event.localId, {
|
|
119
139
|
...item,
|
|
120
140
|
file: item.file,
|
|
121
141
|
intent: item.intent,
|
|
122
142
|
phase: 'ready',
|
|
143
|
+
steps: openStep(item.steps, 'ready', item.attempt ?? 1, now),
|
|
123
144
|
})
|
|
124
145
|
}
|
|
125
146
|
} else {
|
|
126
|
-
//
|
|
147
|
+
// Intent-creation failure path; bump attempt counter.
|
|
148
|
+
const nextAttempt = (item.attempt ?? 1) + 1
|
|
127
149
|
set(event.localId, {
|
|
128
150
|
...item,
|
|
129
151
|
phase: 'creating_intent',
|
|
130
152
|
file: item.file,
|
|
131
|
-
attempt:
|
|
153
|
+
attempt: nextAttempt,
|
|
154
|
+
steps: openStep(item.steps, 'creating_intent', nextAttempt, now),
|
|
132
155
|
})
|
|
133
156
|
}
|
|
134
157
|
break
|
|
@@ -163,7 +186,6 @@ export function createReducer<
|
|
|
163
186
|
return { items }
|
|
164
187
|
}
|
|
165
188
|
|
|
166
|
-
// Internal event handling
|
|
167
189
|
switch (event.type) {
|
|
168
190
|
case 'files.added': {
|
|
169
191
|
const ev = event
|
|
@@ -176,6 +198,8 @@ export function createReducer<
|
|
|
176
198
|
file: item.file,
|
|
177
199
|
fingerprint: item.fingerprint,
|
|
178
200
|
createdAt: item.createdAt,
|
|
201
|
+
meta: item.meta,
|
|
202
|
+
steps: [{ phase: 'validating', enteredAt: now, attempt: 1 }],
|
|
179
203
|
})
|
|
180
204
|
}
|
|
181
205
|
}
|
|
@@ -193,12 +217,13 @@ export function createReducer<
|
|
|
193
217
|
|
|
194
218
|
case 'validation.ok': {
|
|
195
219
|
const item = items.get(event.localId)
|
|
196
|
-
if (
|
|
220
|
+
if (item?.phase !== 'validating') break
|
|
197
221
|
|
|
198
222
|
set(event.localId, {
|
|
199
223
|
...item,
|
|
200
224
|
phase: 'creating_intent',
|
|
201
225
|
attempt: 1,
|
|
226
|
+
steps: openStep(item.steps, 'creating_intent', 1, now),
|
|
202
227
|
})
|
|
203
228
|
break
|
|
204
229
|
}
|
|
@@ -206,15 +231,17 @@ export function createReducer<
|
|
|
206
231
|
case 'validation.failed': {
|
|
207
232
|
const ev = event
|
|
208
233
|
const item = items.get(ev.localId)
|
|
209
|
-
if (
|
|
234
|
+
if (item?.phase !== 'validating') break
|
|
210
235
|
|
|
236
|
+
const err = new UploadValidationError(ev.reason)
|
|
211
237
|
set(ev.localId, {
|
|
212
238
|
...item,
|
|
213
239
|
phase: 'error',
|
|
214
|
-
error:
|
|
240
|
+
error: err,
|
|
215
241
|
retryable: false,
|
|
216
242
|
attempt: 1,
|
|
217
243
|
failedAt: now,
|
|
244
|
+
steps: openStep(item.steps, 'error', 1, now, stripError(err)),
|
|
218
245
|
})
|
|
219
246
|
break
|
|
220
247
|
}
|
|
@@ -222,12 +249,13 @@ export function createReducer<
|
|
|
222
249
|
case 'intent.ok': {
|
|
223
250
|
const ev = event
|
|
224
251
|
const item = items.get(ev.localId)
|
|
225
|
-
if (
|
|
252
|
+
if (item?.phase !== 'creating_intent') break
|
|
226
253
|
|
|
227
254
|
set(ev.localId, {
|
|
228
255
|
...item,
|
|
229
256
|
phase: 'ready',
|
|
230
257
|
intent: ev.intent,
|
|
258
|
+
steps: openStep(item.steps, 'ready', item.attempt ?? 1, now),
|
|
231
259
|
})
|
|
232
260
|
break
|
|
233
261
|
}
|
|
@@ -235,7 +263,7 @@ export function createReducer<
|
|
|
235
263
|
case 'intent.failed': {
|
|
236
264
|
const ev = event
|
|
237
265
|
const item = items.get(ev.localId)
|
|
238
|
-
if (
|
|
266
|
+
if (item?.phase !== 'creating_intent') break
|
|
239
267
|
|
|
240
268
|
set(ev.localId, {
|
|
241
269
|
...item,
|
|
@@ -243,6 +271,7 @@ export function createReducer<
|
|
|
243
271
|
error: ev.error,
|
|
244
272
|
retryable: ev.retryable,
|
|
245
273
|
failedAt: now,
|
|
274
|
+
steps: openStep(item.steps, 'error', item.attempt ?? 1, now, stripError(ev.error)),
|
|
246
275
|
})
|
|
247
276
|
break
|
|
248
277
|
}
|
|
@@ -250,12 +279,12 @@ export function createReducer<
|
|
|
250
279
|
case 'upload.begin': {
|
|
251
280
|
const ev = event
|
|
252
281
|
const item = items.get(ev.localId)
|
|
253
|
-
if (
|
|
282
|
+
if (item?.phase !== 'queued') break
|
|
254
283
|
|
|
255
284
|
const total = item.file.size
|
|
256
|
-
const carried:
|
|
285
|
+
const carried: Engine.Progress | undefined = item.progress
|
|
257
286
|
|
|
258
|
-
const progress:
|
|
287
|
+
const progress: Engine.Progress = carried
|
|
259
288
|
? { ...carried, totalBytes: total, pct: pct(carried.uploadedBytes, total) }
|
|
260
289
|
: { uploadedBytes: 0, totalBytes: total, pct: 0 }
|
|
261
290
|
|
|
@@ -264,6 +293,7 @@ export function createReducer<
|
|
|
264
293
|
phase: 'uploading',
|
|
265
294
|
progress,
|
|
266
295
|
startedAt: ev.startedAt,
|
|
296
|
+
steps: openStep(item.steps, 'uploading', item.attempt ?? 1, ev.startedAt),
|
|
267
297
|
})
|
|
268
298
|
break
|
|
269
299
|
}
|
|
@@ -271,9 +301,9 @@ export function createReducer<
|
|
|
271
301
|
case 'upload.progress': {
|
|
272
302
|
const ev = event
|
|
273
303
|
const item = items.get(ev.localId)
|
|
274
|
-
if (
|
|
304
|
+
if (item?.phase !== 'uploading') break
|
|
275
305
|
|
|
276
|
-
const progress:
|
|
306
|
+
const progress: Engine.Progress = {
|
|
277
307
|
uploadedBytes: ev.uploadedBytes,
|
|
278
308
|
totalBytes: ev.totalBytes,
|
|
279
309
|
pct: pct(ev.uploadedBytes, ev.totalBytes),
|
|
@@ -301,13 +331,14 @@ export function createReducer<
|
|
|
301
331
|
case 'upload.ok': {
|
|
302
332
|
const ev = event
|
|
303
333
|
const item = items.get(ev.localId)
|
|
304
|
-
if (
|
|
334
|
+
if (item?.phase !== 'uploading') break
|
|
305
335
|
|
|
306
336
|
set(ev.localId, {
|
|
307
337
|
...item,
|
|
308
338
|
phase: 'completing',
|
|
309
339
|
progress: { uploadedBytes: item.file.size, totalBytes: item.file.size, pct: 100 },
|
|
310
340
|
completingAt: now,
|
|
341
|
+
steps: openStep(item.steps, 'completing', item.attempt ?? 1, now),
|
|
311
342
|
})
|
|
312
343
|
break
|
|
313
344
|
}
|
|
@@ -315,7 +346,7 @@ export function createReducer<
|
|
|
315
346
|
case 'upload.failed': {
|
|
316
347
|
const ev = event
|
|
317
348
|
const item = items.get(ev.localId)
|
|
318
|
-
if (
|
|
349
|
+
if (item?.phase !== 'uploading') break
|
|
319
350
|
|
|
320
351
|
set(ev.localId, {
|
|
321
352
|
...item,
|
|
@@ -324,6 +355,7 @@ export function createReducer<
|
|
|
324
355
|
retryable: ev.retryable,
|
|
325
356
|
attempt: 1,
|
|
326
357
|
failedAt: now,
|
|
358
|
+
steps: openStep(item.steps, 'error', item.attempt ?? 1, now, stripError(ev.error)),
|
|
327
359
|
})
|
|
328
360
|
break
|
|
329
361
|
}
|
|
@@ -331,13 +363,14 @@ export function createReducer<
|
|
|
331
363
|
case 'paused': {
|
|
332
364
|
const ev = event
|
|
333
365
|
const item = items.get(ev.localId)
|
|
334
|
-
if (
|
|
366
|
+
if (item?.phase !== 'uploading') break
|
|
335
367
|
|
|
336
368
|
set(ev.localId, {
|
|
337
369
|
...item,
|
|
338
370
|
phase: 'paused',
|
|
339
371
|
cursor: ev.cursor,
|
|
340
372
|
pausedAt: ev.pausedAt,
|
|
373
|
+
steps: openStep(item.steps, 'paused', item.attempt ?? 1, ev.pausedAt),
|
|
341
374
|
})
|
|
342
375
|
break
|
|
343
376
|
}
|
|
@@ -354,7 +387,7 @@ export function createReducer<
|
|
|
354
387
|
case 'complete.ok': {
|
|
355
388
|
const ev = event
|
|
356
389
|
const item = items.get(ev.localId)
|
|
357
|
-
if (
|
|
390
|
+
if (item?.phase !== 'completing') break
|
|
358
391
|
|
|
359
392
|
set(ev.localId, {
|
|
360
393
|
...item,
|
|
@@ -362,6 +395,7 @@ export function createReducer<
|
|
|
362
395
|
completedBy: 'upload',
|
|
363
396
|
result: ev.result,
|
|
364
397
|
completedAt: now,
|
|
398
|
+
steps: openStep(item.steps, 'completed', item.attempt ?? 1, now),
|
|
365
399
|
})
|
|
366
400
|
break
|
|
367
401
|
}
|
|
@@ -369,18 +403,20 @@ export function createReducer<
|
|
|
369
403
|
case 'dedupe.ok': {
|
|
370
404
|
const ev = event
|
|
371
405
|
const item = items.get(ev.localId)
|
|
372
|
-
if (!item || item.phase !== 'validating') break
|
|
406
|
+
if (!item || (item.phase !== 'validating' && item.phase !== 'creating_intent')) break
|
|
373
407
|
|
|
374
408
|
set(ev.localId, {
|
|
375
409
|
phase: 'completed',
|
|
376
410
|
localId: item.localId,
|
|
377
|
-
file: item.file,
|
|
411
|
+
file: 'file' in item ? item.file : undefined,
|
|
378
412
|
purpose: item.purpose,
|
|
379
413
|
fingerprint: item.fingerprint,
|
|
380
414
|
result: ev.result,
|
|
381
415
|
completedBy: 'dedupe',
|
|
382
416
|
completedAt: now,
|
|
383
417
|
createdAt: item.createdAt,
|
|
418
|
+
meta: item.meta,
|
|
419
|
+
steps: openStep(item.steps, 'completed', item.attempt ?? 1, now),
|
|
384
420
|
})
|
|
385
421
|
break
|
|
386
422
|
}
|
|
@@ -388,9 +424,8 @@ export function createReducer<
|
|
|
388
424
|
case 'complete.failed': {
|
|
389
425
|
const ev = event
|
|
390
426
|
const item = items.get(ev.localId)
|
|
391
|
-
if (
|
|
427
|
+
if (item?.phase !== 'completing') break
|
|
392
428
|
|
|
393
|
-
// Increment attempt number for retry tracking
|
|
394
429
|
const nextAttempt = (item.attempt ?? 1) + 1
|
|
395
430
|
|
|
396
431
|
set(ev.localId, {
|
|
@@ -400,6 +435,7 @@ export function createReducer<
|
|
|
400
435
|
retryable: ev.retryable,
|
|
401
436
|
attempt: nextAttempt,
|
|
402
437
|
failedAt: now,
|
|
438
|
+
steps: openStep(item.steps, 'error', nextAttempt, now, stripError(ev.error)),
|
|
403
439
|
})
|
|
404
440
|
break
|
|
405
441
|
}
|
|
@@ -409,11 +445,13 @@ export function createReducer<
|
|
|
409
445
|
}
|
|
410
446
|
}
|
|
411
447
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
448
|
+
function toCanceled<
|
|
449
|
+
M extends Contracts.Intent.Map,
|
|
450
|
+
C extends Contracts.Cursor.Map<M>,
|
|
451
|
+
P extends string,
|
|
452
|
+
R extends Contracts.Result.Base,
|
|
453
|
+
>(item: Engine.Item<M, C, P, R>, canceledAt: number): Engine.Item<M, C, P, R> {
|
|
454
|
+
const attempt = ('attempt' in item ? item.attempt : undefined) ?? 1
|
|
417
455
|
return {
|
|
418
456
|
phase: 'canceled',
|
|
419
457
|
localId: item.localId,
|
|
@@ -426,6 +464,8 @@ function toCanceled<M extends IntentMap, C extends CursorMap<M>, P extends strin
|
|
|
426
464
|
cursor: 'cursor' in item ? item.cursor : undefined,
|
|
427
465
|
progress: 'progress' in item ? item.progress : undefined,
|
|
428
466
|
attempt: 'attempt' in item ? item.attempt : undefined,
|
|
467
|
+
meta: item.meta,
|
|
468
|
+
steps: openStep(item.steps, 'canceled', attempt, canceledAt),
|
|
429
469
|
}
|
|
430
470
|
}
|
|
431
471
|
|
|
@@ -1,30 +1,20 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Contracts } from '../../../contracts'
|
|
2
|
+
import { isDevEnv } from '../../../utils/guards'
|
|
2
3
|
import { generateId } from '../../../utils/id'
|
|
3
|
-
import { validateFile, validateFileList } from '../../validation'
|
|
4
|
-
import { calculateFileChecksum, computeFingerprint } from '../store.libs'
|
|
5
|
-
import type {
|
|
4
|
+
import { validateFile, validateFileList, validateMimeSignature } from '../../validation'
|
|
5
|
+
import { calculateFileChecksum, computeFingerprint, tryDedupeByChecksum } from '../store.libs'
|
|
6
|
+
import type { Store } from '../store.types'
|
|
6
7
|
|
|
7
8
|
/**
|
|
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
|
|
9
|
+
* Sync: validate + insert. Async (queued): checksum, dedupe lookup,
|
|
10
|
+
* and the final validation transition.
|
|
21
11
|
*/
|
|
22
12
|
export function handleAddFiles<
|
|
23
|
-
M extends
|
|
24
|
-
C extends
|
|
13
|
+
M extends Contracts.Intent.Map,
|
|
14
|
+
C extends Contracts.Cursor.Map<M>,
|
|
25
15
|
P extends string,
|
|
26
|
-
R extends
|
|
27
|
-
>(rt:
|
|
16
|
+
R extends Contracts.Result.Base,
|
|
17
|
+
>(rt: Store.Runtime<M, C, P, R>, files: File[], purpose: P, meta: Record<string, unknown> = {}) {
|
|
28
18
|
const existingCount = Array.from(rt.state.items.values()).filter(
|
|
29
19
|
(item) => item.purpose === purpose && item.phase !== 'canceled',
|
|
30
20
|
).length
|
|
@@ -36,63 +26,68 @@ export function handleAddFiles<
|
|
|
36
26
|
}
|
|
37
27
|
|
|
38
28
|
const now = Date.now()
|
|
39
|
-
const toAdd: Array<{
|
|
29
|
+
const toAdd: Array<{
|
|
30
|
+
localId: string
|
|
31
|
+
purpose: P
|
|
32
|
+
file: File
|
|
33
|
+
fingerprint: Contracts.FingerprintFile
|
|
34
|
+
createdAt: number
|
|
35
|
+
meta: Record<string, unknown>
|
|
36
|
+
}> = []
|
|
40
37
|
|
|
41
38
|
for (const file of valid) {
|
|
42
39
|
const localId = generateId()
|
|
43
40
|
const fingerprint = (rt.opts.fingerprint ?? computeFingerprint)(file)
|
|
44
41
|
|
|
45
|
-
toAdd.push({ localId, purpose, file, fingerprint, createdAt: now })
|
|
42
|
+
toAdd.push({ localId, purpose, file, fingerprint, createdAt: now, meta })
|
|
46
43
|
|
|
47
44
|
rt.enqueueEffect(async () => {
|
|
48
|
-
// Calculate checksum for deduplication
|
|
49
45
|
let checksum: string | undefined
|
|
50
46
|
try {
|
|
51
|
-
|
|
52
|
-
//
|
|
47
|
+
// Hashes files of any size (small: native Web Crypto; large:
|
|
48
|
+
// incremental, off-thread when possible). Failures fall through to the
|
|
49
|
+
// catch and simply skip dedupe.
|
|
50
|
+
checksum = await calculateFileChecksum(file, rt.opts.config.checksumMaxSize)
|
|
53
51
|
const updatedFingerprint = { ...fingerprint, checksum }
|
|
54
52
|
rt.applyInternal({ type: 'fingerprint.updated', localId, fingerprint: updatedFingerprint })
|
|
55
53
|
} catch (err) {
|
|
56
|
-
//
|
|
57
|
-
if (
|
|
54
|
+
// Checksum failure is non-fatal continue without dedupe.
|
|
55
|
+
if (isDevEnv()) {
|
|
58
56
|
console.warn('[UploadEngine] Failed to calculate checksum:', err)
|
|
59
57
|
}
|
|
60
58
|
}
|
|
61
59
|
|
|
62
|
-
//
|
|
63
|
-
if (checksum
|
|
64
|
-
|
|
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
|
-
}
|
|
60
|
+
// Dedupe: skip upload entirely when the backend already has the file.
|
|
61
|
+
if (await tryDedupeByChecksum(rt, localId, checksum, purpose, ['validating'])) {
|
|
62
|
+
return
|
|
80
63
|
}
|
|
81
64
|
|
|
82
|
-
// Allow extra sync validation hook
|
|
83
65
|
const reason = rt.opts.validateFile?.(file, purpose)
|
|
84
66
|
if (reason) {
|
|
85
67
|
rt.applyInternal({ type: 'validation.failed', localId, reason })
|
|
86
68
|
return
|
|
87
69
|
}
|
|
88
70
|
|
|
89
|
-
//
|
|
71
|
+
// Re-run config validation per-file in case the caller skipped validateFileList.
|
|
90
72
|
const cfgReason = validateFile(file, purpose, rt.opts.config)
|
|
91
73
|
if (cfgReason) {
|
|
92
74
|
rt.applyInternal({ type: 'validation.failed', localId, reason: cfgReason })
|
|
93
75
|
return
|
|
94
76
|
}
|
|
95
77
|
|
|
78
|
+
try {
|
|
79
|
+
const mimeReason = await validateMimeSignature(file, rt.opts.config.strictMimeMatch === true)
|
|
80
|
+
if (mimeReason) {
|
|
81
|
+
rt.applyInternal({ type: 'validation.failed', localId, reason: mimeReason })
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
// Sniff failure is non-fatal fall through.
|
|
86
|
+
if (isDevEnv()) {
|
|
87
|
+
console.warn('[UploadEngine] MIME sniff failed:', err)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
96
91
|
rt.applyInternal({ type: 'validation.ok', localId })
|
|
97
92
|
})
|
|
98
93
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import { hasIntent, isMultipartIntent } from '../store.libs'
|
|
3
|
-
import type {
|
|
1
|
+
import type { Contracts } from '../../../contracts'
|
|
2
|
+
import { buildApiContext, hasIntent, isMultipartIntent } from '../store.libs'
|
|
3
|
+
import type { Store } from '../store.types'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Cancels a single upload item and aborts related inflight operations.
|
|
@@ -8,10 +8,12 @@ import type { StoreRuntime } from '../store.types'
|
|
|
8
8
|
* This function is intentionally best-effort: abort calls can throw or be unsupported,
|
|
9
9
|
* and server-side multipart abort is attempted only when the adapter provides it.
|
|
10
10
|
*/
|
|
11
|
-
export function handleCancel<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
export function handleCancel<
|
|
12
|
+
M extends Contracts.Intent.Map,
|
|
13
|
+
C extends Contracts.Cursor.Map<M>,
|
|
14
|
+
P extends string,
|
|
15
|
+
R extends Contracts.Result.Base,
|
|
16
|
+
>(rt: Store.Runtime<M, C, P, R>, localId: string) {
|
|
15
17
|
const item = rt.state.items.get(localId)
|
|
16
18
|
|
|
17
19
|
// Abort inflight intent
|
|
@@ -43,7 +45,10 @@ export function handleCancel<M extends IntentMap, C extends CursorMap<M>, P exte
|
|
|
43
45
|
try {
|
|
44
46
|
const abort = rt.opts.api.multipart?.abort
|
|
45
47
|
if (abort) {
|
|
46
|
-
await abort(
|
|
48
|
+
await abort(
|
|
49
|
+
{ fileId: intent.fileId, uploadId: intent.uploadId },
|
|
50
|
+
buildApiContext(item) as Contracts.Api.AbortContext<P, M>,
|
|
51
|
+
)
|
|
47
52
|
}
|
|
48
53
|
} catch {
|
|
49
54
|
// ignore
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
3
|
-
import type {
|
|
1
|
+
import type { Contracts } from '../../../contracts'
|
|
2
|
+
import type { Engine } from '../../engine.types'
|
|
3
|
+
import type { Store } from '../store.types'
|
|
4
4
|
|
|
5
5
|
export function cleanupOldItems<
|
|
6
|
-
M extends
|
|
7
|
-
C extends
|
|
6
|
+
M extends Contracts.Intent.Map,
|
|
7
|
+
C extends Contracts.Cursor.Map<M>,
|
|
8
8
|
P extends string,
|
|
9
|
-
R extends
|
|
10
|
-
>(opts:
|
|
9
|
+
R extends Contracts.Result.Base,
|
|
10
|
+
>(opts: Store.Options<M, C, P, R>, state: Engine.State<M, C, P, R>): Engine.State<M, C, P, R> | null {
|
|
11
11
|
const maxItems = opts.config?.maxItems
|
|
12
12
|
const completedTTL = opts.config?.completedItemTTL
|
|
13
13
|
const now = Date.now()
|
|
@@ -50,7 +50,10 @@ export function cleanupOldItems<
|
|
|
50
50
|
|
|
51
51
|
const excess = remainingItems.length - maxItems
|
|
52
52
|
for (let i = 0; i < excess && i < completedOrCanceled.length; i++) {
|
|
53
|
-
|
|
53
|
+
const entry = completedOrCanceled[i]
|
|
54
|
+
if (entry) {
|
|
55
|
+
toRemove.push(entry[0])
|
|
56
|
+
}
|
|
54
57
|
}
|
|
55
58
|
}
|
|
56
59
|
|