@gentleduck/upload 0.2.6 → 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 (136) hide show
  1. package/README.md +170 -193
  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 -51
  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/{__test__ → __tests__}/emitter.test.ts +4 -4
  18. package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
  19. package/src/{__test__ → __tests__}/guards.test.ts +15 -5
  20. package/src/__tests__/id.test.ts +67 -0
  21. package/src/__tests__/mime-sniff.test.ts +142 -0
  22. package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
  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/{__test__ → __tests__}/store-libs.test.ts +57 -3
  27. package/src/__tests__/transport-fetch.test.ts +108 -0
  28. package/src/{__test__ → __tests__}/transport.test.ts +5 -5
  29. package/src/core/__tests__/errors.test.ts +152 -0
  30. package/src/core/client.ts +3 -0
  31. package/src/core/contracts/contracts.types.ts +335 -141
  32. package/src/core/contracts/index.ts +3 -8
  33. package/src/core/contracts/transport/index.ts +3 -2
  34. package/src/core/contracts/transport/transport.fetch.ts +203 -0
  35. package/src/core/contracts/transport/transport.libs.ts +7 -8
  36. package/src/core/contracts/transport/transport.ts +91 -83
  37. package/src/core/contracts/transport/transport.types.ts +71 -35
  38. package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
  39. package/src/core/engine/engine.libs.ts +35 -0
  40. package/src/core/engine/engine.types.ts +367 -241
  41. package/src/core/engine/index.ts +3 -8
  42. package/src/core/engine/reducer.ts +89 -43
  43. package/src/core/engine/store/handlers/add-file.ts +39 -29
  44. package/src/core/engine/store/handlers/cancel.ts +13 -8
  45. package/src/core/engine/store/handlers/clean-up.ts +11 -8
  46. package/src/core/engine/store/handlers/create-intent.ts +82 -27
  47. package/src/core/engine/store/handlers/finalize.ts +30 -13
  48. package/src/core/engine/store/handlers/index.ts +7 -7
  49. package/src/core/engine/store/handlers/pause.ts +8 -6
  50. package/src/core/engine/store/handlers/run-upload.ts +49 -34
  51. package/src/core/engine/store/index.ts +27 -21
  52. package/src/core/engine/store/store.dispatch.ts +10 -8
  53. package/src/core/engine/store/store.libs.ts +184 -41
  54. package/src/core/engine/store/store.runtime.ts +45 -36
  55. package/src/core/engine/store/store.schedule.ts +27 -21
  56. package/src/core/engine/store/store.types.ts +177 -227
  57. package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
  58. package/src/core/engine/validation/file.ts +7 -3
  59. package/src/core/engine/validation/index.ts +1 -0
  60. package/src/core/engine/validation/intent.ts +49 -27
  61. package/src/core/engine/validation/list.ts +14 -15
  62. package/src/core/engine/validation/mime.ts +39 -0
  63. package/src/core/errors.ts +271 -0
  64. package/src/core/index.ts +28 -5
  65. package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
  66. package/src/core/persistence/__tests__/persistence.test.ts +85 -0
  67. package/src/core/persistence/adapters.indexeddb.ts +9 -3
  68. package/src/core/persistence/adapters.local.ts +9 -3
  69. package/src/core/persistence/adapters.memory.ts +35 -13
  70. package/src/core/persistence/index.ts +5 -5
  71. package/src/core/persistence/persistence.ts +73 -60
  72. package/src/core/persistence/persistence.types.ts +140 -60
  73. package/src/core/utils/__tests__/guards.test.ts +93 -0
  74. package/src/core/utils/__tests__/url-safety.test.ts +112 -0
  75. package/src/core/utils/async.ts +7 -2
  76. package/src/core/utils/constants.ts +3 -9
  77. package/src/core/utils/emitter/emitter.ts +7 -24
  78. package/src/core/utils/emitter/emitter.types.ts +33 -16
  79. package/src/core/utils/emitter/index.ts +2 -74
  80. package/src/core/utils/fingerprint.ts +16 -5
  81. package/src/core/utils/guards.ts +91 -1
  82. package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
  83. package/src/core/utils/hash/index.ts +126 -0
  84. package/src/core/utils/hash/sha256.ts +179 -0
  85. package/src/core/utils/id.ts +33 -1
  86. package/src/core/utils/index.ts +15 -6
  87. package/src/core/utils/mime-sniff.ts +119 -0
  88. package/src/core/utils/sanitize-filename.ts +85 -0
  89. package/src/core/utils/url-safety.ts +156 -0
  90. package/src/react/__tests__/use-dropzone.test.ts +64 -0
  91. package/src/react/index.ts +5 -2
  92. package/src/react/upload-provider.tsx +68 -53
  93. package/src/react/uploader.types.ts +58 -61
  94. package/src/react/use-dropzone.tsx +226 -0
  95. package/src/react/use-uploader.tsx +98 -91
  96. package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
  97. package/src/strategies/_shared/retry.ts +79 -0
  98. package/src/strategies/index.ts +9 -15
  99. package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
  100. package/src/strategies/multipart/index.ts +206 -108
  101. package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
  102. package/src/strategies/post/index.ts +115 -33
  103. package/src/strategies/put/__tests__/put.test.ts +132 -0
  104. package/src/strategies/put/index.ts +139 -0
  105. package/src/strategies/registry/index.ts +12 -9
  106. package/src/strategies/tus/__tests__/tus.test.ts +159 -0
  107. package/src/strategies/tus/index.ts +211 -0
  108. package/dist/async-DxRWu4le.js +0 -1
  109. package/dist/core-CMkrP9DX.js +0 -2
  110. package/dist/index-C38Jmizu.d.ts +0 -1005
  111. package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
  112. package/src/__test__/effect-pool.test.ts +0 -5
  113. package/src/__test__/id.test.ts +0 -14
  114. package/src/__test__/store-integration.test.ts +0 -5
  115. package/src/core/client/client.ts +0 -50
  116. package/src/core/client/client.types.ts +0 -184
  117. package/src/core/client/config.types.ts +0 -77
  118. package/src/core/client/index.ts +0 -4
  119. package/src/core/client/plugins.types.ts +0 -46
  120. package/src/core/client/retry.types.ts +0 -6
  121. package/src/core/client/uploadClient.ts +0 -19
  122. package/src/core/contracts/backendApi.ts +0 -62
  123. package/src/core/contracts/cursor.types.ts +0 -23
  124. package/src/core/contracts/errors.types.ts +0 -67
  125. package/src/core/contracts/fingerprint.types.ts +0 -18
  126. package/src/core/contracts/intent.types.ts +0 -39
  127. package/src/core/contracts/result.types.ts +0 -11
  128. package/src/core/contracts/strategy.types.ts +0 -45
  129. package/src/core/engine/commands.types.ts +0 -17
  130. package/src/core/engine/event-map.types.ts +0 -45
  131. package/src/core/engine/internal-events.types.ts +0 -202
  132. package/src/core/engine/outcome.types.ts +0 -9
  133. package/src/core/engine/progress.types.ts +0 -15
  134. package/src/core/engine/store/handlers.ts +0 -819
  135. package/src/core/engine/validation.ts +0 -216
  136. /package/src/{__test__ → __tests__}/transport-libs.test.ts +0 -0
@@ -1,8 +1,3 @@
1
- export * from './commands.types'
2
- export * from './event-map.types'
3
- export * from './internal-events.types'
4
- export * from './outcome.types'
5
- export * from './progress.types'
6
- export * from './reducer'
7
- export * from './store'
8
- export * from './validation'
1
+ export { Engine } from './engine.types'
2
+ export type { Store } from './store'
3
+ export { createUploadStore } from './store'
@@ -1,37 +1,56 @@
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
52
  // Commands have no 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> => {
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
 
@@ -51,6 +70,7 @@ export function createReducer<
51
70
  ...item,
52
71
  phase: 'queued',
53
72
  requestedAt: now,
73
+ steps: openStep(item.steps, 'queued', item.attempt ?? 1, now),
54
74
  })
55
75
  break
56
76
  }
@@ -66,6 +86,7 @@ export function createReducer<
66
86
  file: item.file,
67
87
  phase: 'queued',
68
88
  requestedAt: now,
89
+ steps: openStep(item.steps, 'queued', item.attempt ?? 1, now),
69
90
  })
70
91
  break
71
92
  }
@@ -80,6 +101,7 @@ export function createReducer<
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
107
  break
@@ -101,7 +123,7 @@ export function createReducer<
101
123
  if (!item.file) break
102
124
 
103
125
  if (item.intent) {
104
- // Progress at 100% with intent means we failed in 'completing' — retry that phase.
126
+ // Progress at 100% with intent means we failed in 'completing' retry that phase.
105
127
  if (item.progress && item.progress.pct === 100) {
106
128
  set(event.localId, {
107
129
  ...item,
@@ -110,6 +132,7 @@ export function createReducer<
110
132
  file: item.file,
111
133
  phase: 'completing',
112
134
  completingAt: now,
135
+ steps: openStep(item.steps, 'completing', item.attempt ?? 1, now),
113
136
  })
114
137
  } else {
115
138
  set(event.localId, {
@@ -117,15 +140,18 @@ export function createReducer<
117
140
  file: item.file,
118
141
  intent: item.intent,
119
142
  phase: 'ready',
143
+ steps: openStep(item.steps, 'ready', item.attempt ?? 1, now),
120
144
  })
121
145
  }
122
146
  } else {
123
147
  // Intent-creation failure path; bump attempt counter.
148
+ const nextAttempt = (item.attempt ?? 1) + 1
124
149
  set(event.localId, {
125
150
  ...item,
126
151
  phase: 'creating_intent',
127
152
  file: item.file,
128
- attempt: (item.attempt ?? 1) + 1,
153
+ attempt: nextAttempt,
154
+ steps: openStep(item.steps, 'creating_intent', nextAttempt, now),
129
155
  })
130
156
  }
131
157
  break
@@ -172,6 +198,8 @@ export function createReducer<
172
198
  file: item.file,
173
199
  fingerprint: item.fingerprint,
174
200
  createdAt: item.createdAt,
201
+ meta: item.meta,
202
+ steps: [{ phase: 'validating', enteredAt: now, attempt: 1 }],
175
203
  })
176
204
  }
177
205
  }
@@ -189,12 +217,13 @@ export function createReducer<
189
217
 
190
218
  case 'validation.ok': {
191
219
  const item = items.get(event.localId)
192
- if (!item || item.phase !== 'validating') break
220
+ if (item?.phase !== 'validating') break
193
221
 
194
222
  set(event.localId, {
195
223
  ...item,
196
224
  phase: 'creating_intent',
197
225
  attempt: 1,
226
+ steps: openStep(item.steps, 'creating_intent', 1, now),
198
227
  })
199
228
  break
200
229
  }
@@ -202,15 +231,17 @@ export function createReducer<
202
231
  case 'validation.failed': {
203
232
  const ev = event
204
233
  const item = items.get(ev.localId)
205
- if (!item || item.phase !== 'validating') break
234
+ if (item?.phase !== 'validating') break
206
235
 
236
+ const err = new UploadValidationError(ev.reason)
207
237
  set(ev.localId, {
208
238
  ...item,
209
239
  phase: 'error',
210
- error: { code: 'validation_failed', reason: ev.reason, message: String(ev.reason) },
240
+ error: err,
211
241
  retryable: false,
212
242
  attempt: 1,
213
243
  failedAt: now,
244
+ steps: openStep(item.steps, 'error', 1, now, stripError(err)),
214
245
  })
215
246
  break
216
247
  }
@@ -218,12 +249,13 @@ export function createReducer<
218
249
  case 'intent.ok': {
219
250
  const ev = event
220
251
  const item = items.get(ev.localId)
221
- if (!item || item.phase !== 'creating_intent') break
252
+ if (item?.phase !== 'creating_intent') break
222
253
 
223
254
  set(ev.localId, {
224
255
  ...item,
225
256
  phase: 'ready',
226
257
  intent: ev.intent,
258
+ steps: openStep(item.steps, 'ready', item.attempt ?? 1, now),
227
259
  })
228
260
  break
229
261
  }
@@ -231,7 +263,7 @@ export function createReducer<
231
263
  case 'intent.failed': {
232
264
  const ev = event
233
265
  const item = items.get(ev.localId)
234
- if (!item || item.phase !== 'creating_intent') break
266
+ if (item?.phase !== 'creating_intent') break
235
267
 
236
268
  set(ev.localId, {
237
269
  ...item,
@@ -239,6 +271,7 @@ export function createReducer<
239
271
  error: ev.error,
240
272
  retryable: ev.retryable,
241
273
  failedAt: now,
274
+ steps: openStep(item.steps, 'error', item.attempt ?? 1, now, stripError(ev.error)),
242
275
  })
243
276
  break
244
277
  }
@@ -246,12 +279,12 @@ export function createReducer<
246
279
  case 'upload.begin': {
247
280
  const ev = event
248
281
  const item = items.get(ev.localId)
249
- if (!item || item.phase !== 'queued') break
282
+ if (item?.phase !== 'queued') break
250
283
 
251
284
  const total = item.file.size
252
- const carried: UploadProgress | undefined = item.progress
285
+ const carried: Engine.Progress | undefined = item.progress
253
286
 
254
- const progress: UploadProgress = carried
287
+ const progress: Engine.Progress = carried
255
288
  ? { ...carried, totalBytes: total, pct: pct(carried.uploadedBytes, total) }
256
289
  : { uploadedBytes: 0, totalBytes: total, pct: 0 }
257
290
 
@@ -260,6 +293,7 @@ export function createReducer<
260
293
  phase: 'uploading',
261
294
  progress,
262
295
  startedAt: ev.startedAt,
296
+ steps: openStep(item.steps, 'uploading', item.attempt ?? 1, ev.startedAt),
263
297
  })
264
298
  break
265
299
  }
@@ -267,9 +301,9 @@ export function createReducer<
267
301
  case 'upload.progress': {
268
302
  const ev = event
269
303
  const item = items.get(ev.localId)
270
- if (!item || item.phase !== 'uploading') break
304
+ if (item?.phase !== 'uploading') break
271
305
 
272
- const progress: UploadProgress = {
306
+ const progress: Engine.Progress = {
273
307
  uploadedBytes: ev.uploadedBytes,
274
308
  totalBytes: ev.totalBytes,
275
309
  pct: pct(ev.uploadedBytes, ev.totalBytes),
@@ -297,13 +331,14 @@ export function createReducer<
297
331
  case 'upload.ok': {
298
332
  const ev = event
299
333
  const item = items.get(ev.localId)
300
- if (!item || item.phase !== 'uploading') break
334
+ if (item?.phase !== 'uploading') break
301
335
 
302
336
  set(ev.localId, {
303
337
  ...item,
304
338
  phase: 'completing',
305
339
  progress: { uploadedBytes: item.file.size, totalBytes: item.file.size, pct: 100 },
306
340
  completingAt: now,
341
+ steps: openStep(item.steps, 'completing', item.attempt ?? 1, now),
307
342
  })
308
343
  break
309
344
  }
@@ -311,7 +346,7 @@ export function createReducer<
311
346
  case 'upload.failed': {
312
347
  const ev = event
313
348
  const item = items.get(ev.localId)
314
- if (!item || item.phase !== 'uploading') break
349
+ if (item?.phase !== 'uploading') break
315
350
 
316
351
  set(ev.localId, {
317
352
  ...item,
@@ -320,6 +355,7 @@ export function createReducer<
320
355
  retryable: ev.retryable,
321
356
  attempt: 1,
322
357
  failedAt: now,
358
+ steps: openStep(item.steps, 'error', item.attempt ?? 1, now, stripError(ev.error)),
323
359
  })
324
360
  break
325
361
  }
@@ -327,13 +363,14 @@ export function createReducer<
327
363
  case 'paused': {
328
364
  const ev = event
329
365
  const item = items.get(ev.localId)
330
- if (!item || item.phase !== 'uploading') break
366
+ if (item?.phase !== 'uploading') break
331
367
 
332
368
  set(ev.localId, {
333
369
  ...item,
334
370
  phase: 'paused',
335
371
  cursor: ev.cursor,
336
372
  pausedAt: ev.pausedAt,
373
+ steps: openStep(item.steps, 'paused', item.attempt ?? 1, ev.pausedAt),
337
374
  })
338
375
  break
339
376
  }
@@ -350,7 +387,7 @@ export function createReducer<
350
387
  case 'complete.ok': {
351
388
  const ev = event
352
389
  const item = items.get(ev.localId)
353
- if (!item || item.phase !== 'completing') break
390
+ if (item?.phase !== 'completing') break
354
391
 
355
392
  set(ev.localId, {
356
393
  ...item,
@@ -358,6 +395,7 @@ export function createReducer<
358
395
  completedBy: 'upload',
359
396
  result: ev.result,
360
397
  completedAt: now,
398
+ steps: openStep(item.steps, 'completed', item.attempt ?? 1, now),
361
399
  })
362
400
  break
363
401
  }
@@ -365,18 +403,20 @@ export function createReducer<
365
403
  case 'dedupe.ok': {
366
404
  const ev = event
367
405
  const item = items.get(ev.localId)
368
- if (!item || item.phase !== 'validating') break
406
+ if (!item || (item.phase !== 'validating' && item.phase !== 'creating_intent')) break
369
407
 
370
408
  set(ev.localId, {
371
409
  phase: 'completed',
372
410
  localId: item.localId,
373
- file: item.file,
411
+ file: 'file' in item ? item.file : undefined,
374
412
  purpose: item.purpose,
375
413
  fingerprint: item.fingerprint,
376
414
  result: ev.result,
377
415
  completedBy: 'dedupe',
378
416
  completedAt: now,
379
417
  createdAt: item.createdAt,
418
+ meta: item.meta,
419
+ steps: openStep(item.steps, 'completed', item.attempt ?? 1, now),
380
420
  })
381
421
  break
382
422
  }
@@ -384,7 +424,7 @@ export function createReducer<
384
424
  case 'complete.failed': {
385
425
  const ev = event
386
426
  const item = items.get(ev.localId)
387
- if (!item || item.phase !== 'completing') break
427
+ if (item?.phase !== 'completing') break
388
428
 
389
429
  const nextAttempt = (item.attempt ?? 1) + 1
390
430
 
@@ -395,6 +435,7 @@ export function createReducer<
395
435
  retryable: ev.retryable,
396
436
  attempt: nextAttempt,
397
437
  failedAt: now,
438
+ steps: openStep(item.steps, 'error', nextAttempt, now, stripError(ev.error)),
398
439
  })
399
440
  break
400
441
  }
@@ -404,10 +445,13 @@ export function createReducer<
404
445
  }
405
446
  }
406
447
 
407
- function toCanceled<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
408
- item: UploadItem<M, C, P, R>,
409
- canceledAt: number,
410
- ): 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
411
455
  return {
412
456
  phase: 'canceled',
413
457
  localId: item.localId,
@@ -420,6 +464,8 @@ function toCanceled<M extends IntentMap, C extends CursorMap<M>, P extends strin
420
464
  cursor: 'cursor' in item ? item.cursor : undefined,
421
465
  progress: 'progress' in item ? item.progress : undefined,
422
466
  attempt: 'attempt' in item ? item.attempt : undefined,
467
+ meta: item.meta,
468
+ steps: openStep(item.steps, 'canceled', attempt, canceledAt),
423
469
  }
424
470
  }
425
471
 
@@ -1,19 +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
9
  * Sync: validate + insert. Async (queued): checksum, dedupe lookup,
9
10
  * and the final validation transition.
10
11
  */
11
12
  export function handleAddFiles<
12
- M extends IntentMap,
13
- C extends CursorMap<M>,
13
+ M extends Contracts.Intent.Map,
14
+ C extends Contracts.Cursor.Map<M>,
14
15
  P extends string,
15
- R extends UploadResultBase,
16
- >(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> = {}) {
17
18
  const existingCount = Array.from(rt.state.items.values()).filter(
18
19
  (item) => item.purpose === purpose && item.phase !== 'canceled',
19
20
  ).length
@@ -25,44 +26,40 @@ export function handleAddFiles<
25
26
  }
26
27
 
27
28
  const now = Date.now()
28
- 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
+ }> = []
29
37
 
30
38
  for (const file of valid) {
31
39
  const localId = generateId()
32
40
  const fingerprint = (rt.opts.fingerprint ?? computeFingerprint)(file)
33
41
 
34
- toAdd.push({ localId, purpose, file, fingerprint, createdAt: now })
42
+ toAdd.push({ localId, purpose, file, fingerprint, createdAt: now, meta })
35
43
 
36
44
  rt.enqueueEffect(async () => {
37
45
  let checksum: string | undefined
38
46
  try {
39
- checksum = await calculateFileChecksum(file)
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)
40
51
  const updatedFingerprint = { ...fingerprint, checksum }
41
52
  rt.applyInternal({ type: 'fingerprint.updated', localId, fingerprint: updatedFingerprint })
42
53
  } catch (err) {
43
- // Checksum failure is non-fatal — continue without dedupe.
44
- if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
54
+ // Checksum failure is non-fatal continue without dedupe.
55
+ if (isDevEnv()) {
45
56
  console.warn('[UploadEngine] Failed to calculate checksum:', err)
46
57
  }
47
58
  }
48
59
 
49
60
  // Dedupe: skip upload entirely when the backend already has the file.
50
- if (checksum && rt.opts.api.findByChecksum) {
51
- try {
52
- const existingFile = await rt.opts.api.findByChecksum({ checksum, purpose })
53
- if (existingFile) {
54
- const currentItem = rt.state.items.get(localId)
55
- if (currentItem && currentItem.phase === 'validating') {
56
- rt.applyInternal({ type: 'dedupe.ok', localId, result: existingFile })
57
- return
58
- }
59
- }
60
- } catch (err) {
61
- // Dedupe failure is non-fatal — fall through to normal upload.
62
- if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
63
- console.warn('[UploadEngine] Failed to check for existing file:', err)
64
- }
65
- }
61
+ if (await tryDedupeByChecksum(rt, localId, checksum, purpose, ['validating'])) {
62
+ return
66
63
  }
67
64
 
68
65
  const reason = rt.opts.validateFile?.(file, purpose)
@@ -78,6 +75,19 @@ export function handleAddFiles<
78
75
  return
79
76
  }
80
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
+
81
91
  rt.applyInternal({ type: 'validation.ok', localId })
82
92
  })
83
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