@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.
Files changed (129) hide show
  1. package/README.md +297 -0
  2. package/dist/core/index.d.ts +2 -3
  3. package/dist/core/index.js +1 -1
  4. package/dist/core-BVgloTiP.js +10 -0
  5. package/dist/index-DCqyBVcx.d.ts +1441 -0
  6. package/dist/index-DsZHPmJm.d.ts +225 -0
  7. package/dist/index-KROXtLQh.d.ts +281 -0
  8. package/dist/index.d.ts +4 -5
  9. package/dist/index.js +1 -1
  10. package/dist/react/index.d.ts +2 -90
  11. package/dist/react/index.js +1 -1
  12. package/dist/react-euPrDv24.js +1 -0
  13. package/dist/strategies/index.d.ts +2 -56
  14. package/dist/strategies/index.js +1 -1
  15. package/dist/strategies-CUF0s68j.js +1 -0
  16. package/package.json +3 -2
  17. package/src/__tests__/emitter.test.ts +85 -0
  18. package/src/__tests__/fingerprint.test.ts +49 -0
  19. package/src/__tests__/guards.test.ts +40 -0
  20. package/src/__tests__/id.test.ts +67 -0
  21. package/src/__tests__/mime-sniff.test.ts +142 -0
  22. package/src/__tests__/persistence.test.ts +191 -0
  23. package/src/__tests__/sanitize-filename.test.ts +109 -0
  24. package/src/__tests__/sleep.test.ts +34 -0
  25. package/src/__tests__/static-analysis.test.ts +145 -0
  26. package/src/__tests__/store-libs.test.ts +192 -0
  27. package/src/__tests__/transport-fetch.test.ts +108 -0
  28. package/src/__tests__/transport-libs.test.ts +42 -0
  29. package/src/__tests__/transport.test.ts +182 -0
  30. package/src/core/__tests__/errors.test.ts +152 -0
  31. package/src/core/client.ts +3 -0
  32. package/src/core/contracts/contracts.types.ts +373 -0
  33. package/src/core/contracts/index.ts +3 -8
  34. package/src/core/contracts/transport/index.ts +3 -2
  35. package/src/core/contracts/transport/transport.fetch.ts +203 -0
  36. package/src/core/contracts/transport/transport.libs.ts +13 -39
  37. package/src/core/contracts/transport/transport.ts +96 -90
  38. package/src/core/contracts/transport/transport.types.ts +71 -35
  39. package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
  40. package/src/core/engine/engine.libs.ts +35 -0
  41. package/src/core/engine/engine.types.ts +490 -0
  42. package/src/core/engine/index.ts +3 -8
  43. package/src/core/engine/reducer.ts +93 -53
  44. package/src/core/engine/store/handlers/add-file.ts +43 -48
  45. package/src/core/engine/store/handlers/cancel.ts +13 -8
  46. package/src/core/engine/store/handlers/clean-up.ts +11 -8
  47. package/src/core/engine/store/handlers/create-intent.ts +82 -27
  48. package/src/core/engine/store/handlers/finalize.ts +30 -13
  49. package/src/core/engine/store/handlers/index.ts +7 -7
  50. package/src/core/engine/store/handlers/pause.ts +8 -6
  51. package/src/core/engine/store/handlers/run-upload.ts +54 -39
  52. package/src/core/engine/store/index.ts +27 -21
  53. package/src/core/engine/store/store.dispatch.ts +10 -8
  54. package/src/core/engine/store/store.libs.ts +197 -94
  55. package/src/core/engine/store/store.runtime.ts +52 -53
  56. package/src/core/engine/store/store.schedule.ts +27 -21
  57. package/src/core/engine/store/store.types.ts +177 -227
  58. package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
  59. package/src/core/engine/validation/file.ts +8 -4
  60. package/src/core/engine/validation/index.ts +1 -0
  61. package/src/core/engine/validation/intent.ts +49 -27
  62. package/src/core/engine/validation/list.ts +14 -15
  63. package/src/core/engine/validation/mime.ts +39 -0
  64. package/src/core/errors.ts +271 -0
  65. package/src/core/index.ts +28 -5
  66. package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
  67. package/src/core/persistence/__tests__/persistence.test.ts +85 -0
  68. package/src/core/persistence/adapters.indexeddb.ts +9 -3
  69. package/src/core/persistence/adapters.local.ts +9 -3
  70. package/src/core/persistence/adapters.memory.ts +35 -13
  71. package/src/core/persistence/index.ts +5 -5
  72. package/src/core/persistence/persistence.ts +73 -60
  73. package/src/core/persistence/persistence.types.ts +140 -60
  74. package/src/core/utils/__tests__/guards.test.ts +93 -0
  75. package/src/core/utils/__tests__/url-safety.test.ts +112 -0
  76. package/src/core/utils/async.ts +7 -2
  77. package/src/core/utils/constants.ts +3 -9
  78. package/src/core/utils/emitter/emitter.ts +126 -0
  79. package/src/core/utils/emitter/emitter.types.ts +33 -16
  80. package/src/core/utils/emitter/index.ts +2 -74
  81. package/src/core/utils/fingerprint.ts +16 -5
  82. package/src/core/utils/guards.ts +91 -1
  83. package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
  84. package/src/core/utils/hash/index.ts +126 -0
  85. package/src/core/utils/hash/sha256.ts +179 -0
  86. package/src/core/utils/id.ts +33 -1
  87. package/src/core/utils/index.ts +15 -6
  88. package/src/core/utils/mime-sniff.ts +119 -0
  89. package/src/core/utils/sanitize-filename.ts +85 -0
  90. package/src/core/utils/url-safety.ts +156 -0
  91. package/src/react/__tests__/use-dropzone.test.ts +64 -0
  92. package/src/react/index.ts +5 -2
  93. package/src/react/upload-provider.tsx +59 -95
  94. package/src/react/uploader.types.ts +80 -0
  95. package/src/react/use-dropzone.tsx +226 -0
  96. package/src/react/use-uploader.tsx +97 -93
  97. package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
  98. package/src/strategies/_shared/retry.ts +79 -0
  99. package/src/strategies/index.ts +9 -15
  100. package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
  101. package/src/strategies/multipart/index.ts +211 -116
  102. package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
  103. package/src/strategies/post/index.ts +113 -71
  104. package/src/strategies/put/__tests__/put.test.ts +132 -0
  105. package/src/strategies/put/index.ts +139 -0
  106. package/src/strategies/registry/index.ts +12 -9
  107. package/src/strategies/tus/__tests__/tus.test.ts +159 -0
  108. package/src/strategies/tus/index.ts +211 -0
  109. package/dist/async-DxRWu4le.js +0 -1
  110. package/dist/core-BNxznRif.js +0 -2
  111. package/dist/index-BXg9WIce.d.ts +0 -987
  112. package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
  113. package/src/core/client/config.types.ts +0 -77
  114. package/src/core/client/index.ts +0 -4
  115. package/src/core/client/plugins.types.ts +0 -46
  116. package/src/core/client/retry.types.ts +0 -6
  117. package/src/core/client/uploadClient.ts +0 -19
  118. package/src/core/contracts/backendApi.ts +0 -62
  119. package/src/core/contracts/cursor.types.ts +0 -23
  120. package/src/core/contracts/errors.types.ts +0 -67
  121. package/src/core/contracts/fingerprint.types.ts +0 -18
  122. package/src/core/contracts/intent.types.ts +0 -39
  123. package/src/core/contracts/result.types.ts +0 -11
  124. package/src/core/contracts/strategy.types.ts +0 -45
  125. package/src/core/engine/commands.types.ts +0 -17
  126. package/src/core/engine/event-map.types.ts +0 -45
  127. package/src/core/engine/internal-events.types.ts +0 -222
  128. package/src/core/engine/outcome.types.ts +0 -9
  129. package/src/core/engine/progress.types.ts +0 -15
@@ -1,41 +1,59 @@
1
- import type { CursorMap, IntentMap, UploadResultBase } from '../contracts'
1
+ import type { Contracts } from '../contracts'
2
+ import { UploadValidationError } from '../errors'
2
3
  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'
4
+ import type { Engine } from './engine.types'
6
5
 
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>>
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 IntentMap,
19
- C extends CursorMap<M>,
37
+ M extends Contracts.Intent.Map,
38
+ C extends Contracts.Cursor.Map<M>,
20
39
  P extends string,
21
- R extends UploadResultBase = UploadResultBase,
40
+ R extends Contracts.Result.Base = Contracts.Result.Base,
22
41
  >() {
23
42
  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> {
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: UploadItem<M, C, P, R>) => items.set(localId, 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
- // 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> => {
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
- // If it's queued but not started, revert back to ready.
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
- // If we have progress and intent, we were in completing phase - retry completing
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
- // For intent creation failures, increment attempt on retry
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: (item.attempt ?? 1) + 1,
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 (!item || item.phase !== 'validating') break
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 (!item || item.phase !== 'validating') break
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: { code: 'validation_failed', reason: ev.reason, message: String(ev.reason) },
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 (!item || item.phase !== 'creating_intent') break
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 (!item || item.phase !== 'creating_intent') break
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 (!item || item.phase !== 'queued') break
282
+ if (item?.phase !== 'queued') break
254
283
 
255
284
  const total = item.file.size
256
- const carried: UploadProgress | undefined = item.progress
285
+ const carried: Engine.Progress | undefined = item.progress
257
286
 
258
- const progress: UploadProgress = carried
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 (!item || item.phase !== 'uploading') break
304
+ if (item?.phase !== 'uploading') break
275
305
 
276
- const progress: UploadProgress = {
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 (!item || item.phase !== 'uploading') break
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 (!item || item.phase !== 'uploading') break
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 (!item || item.phase !== 'uploading') break
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 (!item || item.phase !== 'completing') break
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 (!item || item.phase !== 'completing') break
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
- // 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> {
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 { CursorMap, FileFingerprint, IntentMap, UploadResultBase } from '../../../contracts'
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 { StoreRuntime } from '../store.types'
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
- * 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
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 IntentMap,
24
- C extends CursorMap<M>,
13
+ M extends Contracts.Intent.Map,
14
+ C extends Contracts.Cursor.Map<M>,
25
15
  P extends string,
26
- R extends UploadResultBase,
27
- >(rt: StoreRuntime<M, C, P, R>, files: File[], purpose: P) {
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<{ localId: string; purpose: P; file: File; fingerprint: FileFingerprint; createdAt: number }> = []
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
- checksum = await calculateFileChecksum(file)
52
- // Update fingerprint with checksum
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
- // If checksum calculation fails, continue without checksum
57
- if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
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
- // 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
- }
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
- // Still run config validation per-file (covers case where caller skipped validateFileList)
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 { CursorMap, IntentMap, UploadResultBase } from '../../../contracts'
2
- import { hasIntent, isMultipartIntent } from '../store.libs'
3
- import type { StoreRuntime } from '../store.types'
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<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
12
- rt: StoreRuntime<M, C, P, R>,
13
- localId: string,
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({ fileId: intent.fileId, uploadId: intent.uploadId })
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 { CursorMap, IntentMap, UploadResultBase } from '../../../contracts'
2
- import type { UploadState } from '../../reducer'
3
- import type { StoreOptions } from '../store.types'
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 IntentMap,
7
- C extends CursorMap<M>,
6
+ M extends Contracts.Intent.Map,
7
+ C extends Contracts.Cursor.Map<M>,
8
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 {
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
- toRemove.push(completedOrCanceled[i][0])
53
+ const entry = completedOrCanceled[i]
54
+ if (entry) {
55
+ toRemove.push(entry[0])
56
+ }
54
57
  }
55
58
  }
56
59